• 8

Swift程式設計[第4單元] SwiftUI動畫與繪圖

第11課 PhotosPicker (1)相簿單選

[2024/12/24更新]
  1. 新版macOS 15的PhotosPicker預設行為與最初不同,必須加上 .photosPickerStyle(.inline),否則無法選擇照片,選擇後也不會自己消失。
  2. 測試環境:Mac mini 2023 (M2), macOS 15.2, Swift Playgrounds 4.5.1

在第4單元第9課學習圖片輪播時,後半段(4-9e)曾製作一個相簿App,當時開啟相簿的功能是透過UIKit 物件,也就是在SwiftUI中橋接UIKit的方式,相當麻煩。

Swift Playgrounds 4.2 之後,終於可以直接在SwiftUI中開啟相簿,這個物件名為 PhotosPicker (相片選擇器),與UIKit用的PHPicker 同樣放在 PhotosUI 框架中,需要 import PhotosUI 才能使用。

PhotosPicker 是一個視圖物件,因此用法跟其他視圖類似,非常簡單,但是背後過程卻不容易理解,因此我們從最簡化的範例開始(在電子書 .playgroundbook 模式下測試):
// 4-11a PhotosPicker開啟相簿
// Created by Heman, 2022/10/29
// Updated by Heman, 2024/12/24. (增加 .photosPickerStyle(.inline) 兩行)
import SwiftUI
import PhotosUI

struct 開啟相簿: View {
@State var 選擇: PhotosPickerItem?
var body: some View {
PhotosPicker(selection: $選擇, matching: .images) {
Text("請選擇照片(單選)")
.font(.title)
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(開啟相簿())

PhotosPicker最少需要兩個參數(匿名函式也算參數):

(1) 括號(selection: $選擇)是一個要帶回選擇內容的參數(注意 $ 符號)
(2) 匿名函式 { } 則是提示開啟相簿的「標籤按鈕」,可放任何視圖,此例用 Text() 呈現

注意括號內參數「選擇」的類型,並不是我們期望的 Image 或 UIImage,而是一個新的類型:PhotosPickerItem。若執行上述範例的話,會出現相簿沒錯,但選擇圖片後並不會顯示圖片,而是重回到「請選擇照片(單選)」的標籤按鈕。如以下執行結果:


想知道傳回的 PhotosPickerItem 到底是什麼內容嗎?我們修改範例程式來偷看一下:
// 4-11b 查看PhotosPickerItem
// Created by Heman, 2022/10/29
// Updated by Heman, 2024/10/27. (增加 .photosPickerStyle(.inline) 兩行
import SwiftUI
import PhotosUI

struct 開啟相簿: View {
@State var 選擇: PhotosPickerItem?
var body: some View {
PhotosPicker(selection: $選擇) {
Text("請選擇照片(單選)")
.font(.title)
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 選擇) { newItem in
print(newItem)
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(開啟相簿())

4-11b 加了 .onChange() 修飾語,當參數「選擇」變更時,就會將內容 print 出來,執行結果列印到主控台的內容如下(有編排過):
Optional(_PhotosUI_SwiftUI.PhotosPickerItem(
_itemIdentifier: FDE85F22-6B6E-449B-90DF-EDD345B0580B/L0/001,
_shouldExposeItemIdentifier: false,
_supportedContentTypes: [<_UTCoreType 0x7ff846f59bc0> public.jpeg (not dynamic, declared)],
_itemProvider: <puphotosfileprovideritemprovider: 0x7fc7a97acd90=""> {types =
("com.apple.private.live-photo-bundle", "public.jpeg")}
))

可以看到 PhotosPickerItem 包含4個屬性,名稱都是以底線 _ 開頭,表示這是系統內部結構,僅供 Apple 使用,所以我們無需深究,只需知道這不是我們要的 Image 或 UIImage。

那麼要如何才能得到所選照片呢?根據原廠文件,必須用 PhotosPickerItem 的物件方法 loadTransferable() 才能取得照片資料,也就是說,上面列印出來的 PhotosPickerItem 4個屬性,其實只是取得照片的「線索」。

進一步改寫範例如下:
// 4-11c loadTransferable()
// Created by Heman, 2022/10/29
// Updated by Heman, 2024/10/27. (增加 .photosPickerStyle(.inline) 兩行
import SwiftUI
import PhotosUI

struct 開啟相簿: View {
@State var 選擇: PhotosPickerItem?
@State var 圖片: UIImage?
var body: some View {
PhotosPicker(selection: $選擇) {
Text("請選擇照片(單選)")
.font(.title)
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 選擇) { newItem in
print(newItem)
Task {
do {
if let 原始資料 = try await newItem?.loadTransferable(type: Data.self) {
if let 轉換圖片 = UIImage(data: 原始資料) {
圖片 = 轉換圖片
print(圖片)
}
}
} catch {
print("無法取得或轉換照片: \(error)")
}
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(開啟相簿())

loadTransferable() 是一個非同步 async throws 函式,須以 try await 呼叫,所以用 Task-do-try-await-catch 標準句型。等 loadTransferable() 取得資料後,還要用 UIImage 轉換為圖片格式,才能在 Image 視圖顯示出來。

這段用 try await 取得原始資料,再經由 UIImage 轉換的寫法,是否覺得似曾相識?沒錯,在第9課一開始4-9a也曾用過,當時呼叫的是URLSession.shared.data():
// 4-9a 片段
Task {
if let myURL = URL(string: 網址) {
do {
let (內容, 回應碼) = try await URLSession.shared.data(from: myURL)
if let 轉圖 = UIImage(data: 內容) {
...
}
} catch {
print("有錯誤發生")
}
}
}

到這裡我們可以推測,loadTransferable() 和 URLSession.shared.data() 功能類似,都是到某個地方抓圖。但是,相簿不就在我們自己電腦裡面嗎?

未必如此,因為 Apple 已將相簿與 iCloud 整合,也就是說,有些情況下,照片有可能在雲端,而不在本機。所以透過非同步的 loadTransferable(),不管照片在本機或在雲端,loadTransferable() 都會幫我們抓下來。

到此我們終於拿到從相簿選擇的照片,接下來就可以用 Image 顯示出來。再次修改範例,模擬一個常見的會員頭像設定,當還未選擇照片時,先放系統圖示"person.circle",選擇後再改成照片,兩者都以 .mask(Circle()) 剪裁成圓形。

注意圖片視圖 Image() 放在原來「標籤按鈕」Text() 的位置,所以圖片本身就是按鈕,按一下就可開啟相簿重新選擇。

完整程式碼如下:
// 4-11d 選擇頭像照片
// Created by Heman, 2022/10/29
// Updated by Heman, 2024/10/27. 全部改寫
import SwiftUI
import PhotosUI

struct 選擇頭像: View {
@State var 單選: PhotosPickerItem?
@State var 頭像照: UIImage? = nil
var body: some View {
if 頭像照 == nil {
PhotosPicker(selection: $單選) {
Image(systemName: "person.circle")
.resizable()
.scaledToFit()
.mask(Circle())
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 單選) { newItem in
Task {
do {
if let 原始資料 = try await newItem?.loadTransferable(type: Data.self) {
if let 轉換圖片 = UIImage(data: 原始資料) {
頭像照 = 轉換圖片
}
}
} catch {
print("無法取得或轉換照片: \(error)")
}
}
}
} else {
Image(uiImage: 頭像照!)
.resizable()
.scaledToFit()
.mask(Circle())
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(選擇頭像())


執行結果:


💡 註解
  1. Apple 為了做 PhotosPicker 視圖,設計了一個新的規範(protocol),稱為 Transferable (字面上是「可轉移的」意思),用來實現兩個App之間的資料傳遞。
  2. PhotosPicker 開啟的相簿,其實是另一個App(即作業系統內建的「照片」App)操作的,資料實際是從「照片」App傳遞過來,因此稱為 loadTransferable()。
  3. Transferable 規範除了傳遞照片之外,未來還可以讓SwiftUI實現「剪貼(Clipboard)」或「拖放(Drag-and-drop)」等功能。
挑戰題:蜂巢形遮罩(mask)

上一節末尾將頭像照片裁切成圓形,用的是 .mask() 修飾語,術語稱為「遮罩」,遮罩修飾語非常好用,我們最早曾在第2單元第5課學過,當時以不規則形的Apple商標圖示為遮罩,做了一個「七彩蘋果」。

.mask() 的參數是個視圖,但也不是所有視圖都能當遮罩,除了內建的系統圖示之外,通常用畫布視圖 Canvas (第4單元第5課)或形狀規範 Shape (第4單元第8課)畫出的輪廓圖案較合適。

活用遮罩可以讓照片更出色,以下就是用 Shape 畫出一個正六邊形,再以正六邊形鋪滿整個螢幕(同樣也是 Shape),作為遮罩的效果。

設計這個遮罩的方法,就在第4單元第5~8課中,有興趣的同學,要不要挑戰看看?

提示:程式片段如下
struct 正六邊形鋪磚: Shape {
...
}

struct 選擇頭像: View {
@State var 頭像照: UIImage?
var body: some View {
...
Image(uiImage: 頭像照!)
.mask(正六邊形鋪磚())
...
}
}

💡 註解
  1. 用幾何形狀鋪滿平面的問題,在數學上稱為「平面鋪磚問題(Tiling Problem)」,入門很簡單,任何學過多邊形的中學生,都能嘗試,但要精通並以數學分析,則屬大師級,有位諾貝爾得主的數學家,就對這個問題鑽研到極致。
  2. 諾貝爾獎並無數學獎,所以數學家獲得諾貝爾獎非常少見,最著名的有兩位,一位是以賽局理論獲得1994年經濟學獎的John Nash (納什),其故事還改編成電影;另一位是 Roger Penrose (潘洛斯),因為研究黑洞與廣義相對論的關係,獲得2020年諾貝爾物理學獎。
  3. 潘洛斯早年對鋪磚問題非常感興趣,還發明一種正五邊形(搭配菱形)的平面鋪磚法,就稱為潘洛斯鋪磚法(Penrose Tiling)。
  4. 平面鋪磚問題並未被研究透徹,還有很多空間待發掘。
4-11e PhotosPicker(2)相簿輪播

前面我們已學會用PhotosPicker開啟相簿,雖然每次只能選擇一張照片,但重點是了解背後Transferable 非同步的運作方式。PhotosPicker單選的標準句型如下:
// 相簿單選
struct 開啟相簿: View {
@State var 選擇: PhotosPickerItem?
@State var 圖片: UIImage?
var body: some View {
PhotosPicker(selection: $選擇) {
Text("請選擇照片(單選)")
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 選擇) { newItem in
Task {
do {
if let 原始資料 = try await newItem?.loadTransferable(type: Data.self) {
if let 轉換圖片 = UIImage(data: 原始資料) {
圖片 = 轉換圖片
}
}
} catch {
print("無法取得或轉換照片: \(error)")
}
}
}
}
}

整個過程大致分成4個步驟:

(1) PhotosPicker() 開啟相簿
(2) 選擇相片,將所選相片的「線索」寫入「$選擇」帶回
(3) 根據「選擇」的線索,呼叫 loadTransferable() 取回圖片原始資料
(4) 將圖片原始資料轉成 UIImage 圖片格式

知道如何選擇單張照片之後,要改成複選就容易多了。只要將「選擇」類型改成陣列 [PhotosPickerItem] ,再加一個 for 迴圈逐項處理即可。複選的標準句型如下:
// 相簿複選
struct 開啟相簿_SwiftUI: View {
@State var 複選: [PhotosPickerItem] = []
@State var 照片選輯: [UIImage] = []
var body: some View {
PhotosPicker(selection: $複選, matching: .images) {
Text("請選擇照片(複選)")
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 複選) { 複選結果 in
照片選輯 = []
for 選項 in 複選結果 {
Task {
do {
if let 原始資料 = try await 選項.loadTransferable(type: Data.self) {
if let 轉換照片 = UIImage(data: 原始資料) {
照片選輯.append(轉換照片)
}
}
} catch {
print("有問題:\(error)")
}
}
}
}
}
}


在 PhotosPicker() 增加一個參數:matching: .images,只顯示 JPG, PNG 等靜態照片,而不會顯示影片檔案。

matching 參數用來指定或過濾相片種類,種類包括:

1. images: 所有靜態相片、圖片(.jpg, .png)
2. livePhotos: 原況照片(.heic)
3. panoramas: 全景照片
4. bursts: 連拍模式
5. depthEffectPhotos: 景深模式
6. screenshots: 螢幕快照
7. screenRecordings: 螢幕錄影
8. videos: 所有動態影片格式
9. slomoVideos: 慢動作影片
10. timelapseVideos: 縮時攝影
11. cinematicVideos: 電影模式 (iPhone 13以後)
12. any: 任何組合
13. all: 所有格式
14. not: 排除

相簿輪播是以App模式撰寫,物件名稱不能重複,因為之前第9課已有一個 struct 物件取名為「開啟相簿」,因此這裡的物件改名為「開啟相簿_SwiftUI」。若將上面標準句型寫到App模式,會出現以下錯誤訊息:


App 模式下,Swift Playgrounds 4.2 會對最新物件顯示 App 相容性警告,因為如此一來,只有升級到 iOS 16.0 以後才能跑這個App。

要消除這個警告,必須在物件宣告之前,加上 @available(iOS 16.0, *),這與 @State 或 @Binding 類似,是在物件外面加一層屬性包裝,表明在iOS 16以上才能編譯執行。

@available() 可以指定的作業系統包括 iOS, macOS, macCatalyst, watchOS, tvOS 等,但因為 Swift Playgrounds 主要支援 iOS App,因此只寫 @available(iOS 16.0, *) 即可。

此外,之前是用 .sheet 開啟相簿,因此配合增加一個「開關」參數,選完照片之後,就將開關關閉,「照片選輯」與「開關」設為 @Binding 雙向參數。

修改後完整程式碼如下:
// 開啟相簿 -- 改用 PhotosPicker 取代第一段程式
// Created by Heman, 2022/11/01
import SwiftUI
import PhotosUI

@available(iOS 16.0, *)
struct 開啟相簿_SwiftUI: View {
@State var 複選: [PhotosPickerItem] = []
@Binding var 照片選輯: [UIImage]
@Binding var 開關: Bool
var body: some View {
PhotosPicker(selection: $複選, matching: .images)
{
Text("請選擇照片(複選)")
}
.buttonStyle(.bordered)
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 複選) { 複選結果 in
照片選輯 = []
for 選項 in 複選結果 {
Task {
do {
if let 原始資料 = try await 選項.loadTransferable(type: Data.self) {
if let 轉換照片 = UIImage(data: 原始資料) {
照片選輯.append(轉換照片)
}
}
} catch {
print("有問題:\(error)")
}
}
}
開關.toggle()
}
}
}


配合這個新版「開啟相簿_SwiftUI」,相對應的呼叫方式如下(在「相簿圖片輪播」中):
// 第二段程式:相簿圖片輪播.swift 部分片段
.sheet(isPresented: $相簿開關) {
if #available(iOS 16.0, *) {
開啟相簿_SwiftUI(照片選輯: $圖片集, 開關: $相簿開關) // PhotosPicker
} else {
開啟相簿(result: $圖片集, popup: $相簿開關) // 舊版用UIKit橋接
}
}

這裡 #available() 與 @available() 同樣意思,差別只是 @available 用在 struct 宣告之前,而 #available 用在邏輯運算式當中。

加了 #available 的好處是兩個「相簿開關」版本都可以保留下來,如果 iOS 16 以上,會用新物件 PhotosPicker,否則舊版就用UIKit物件 PHPicker,這樣可以保持良好相容性。

不過兩者還是有個小差別,PhotosPicker 會先顯示「標籤按鈕」,而 PHPicker 則可直接顯示相簿。


💡 註解

1. 相簿複選的 for 迴圈逐項處理這段程式:
for 選項 in 複選結果 {
Task {
do {
if let 原始資料 = try await 選項.loadTransferable(type: Data.self) {
if let 轉換照片 = UIImage(data: 原始資料) {
照片選輯.append(轉換照片)
}
}
} catch {
print("有問題:\(error)")
}
}
}

若將前兩行調換如下,能否正確執行呢?兩者有何差別呢?
Task {
for 選項 in 複選結果 {
do {
if let 原始資料 = try await 選項.loadTransferable(type: Data.self) {
if let 轉換照片 = UIImage(data: 原始資料) {
照片選輯.append(轉換照片)
}
}
} catch {
print("有問題:\(error)")
}
}
}
第12課 Charts 統計圖表

網路時代資訊隨手可得,各類訊息無窮無盡,時間反而變成有限資源,如何篩選、過濾、快速理解,變得非常重要。圖表就是化繁為簡的最佳工具,所謂一圖抵千言,一張好圖表比文章更易受歡迎。

前面提過,Apple新推出的 Charts 框架是今(2022)年SwiftUI最重要的更新之一,如果配合去(2021)年的 TabularData 框架,一個「作圖」一個「製表」,面對大數據將如虎添翼,這兩個框架很值得用一整個單元深入學習,但本課就先對這兩個框架淺嚐一下。

下圖是筆者收藏一份民國31年山東青島文德女中初三學生(相當於九年級)成績單,可看到80年前的中學生需要學習13個科目。

我們來試著用 Charts 畫出成績統計圖。第一步先數位化,將實體表格化為電腦可處理的數位資料,此步驟有很多方法,我們先用最簡單的,設計兩個欄位:科目名稱與成績,然後將表格第一列(第一次平時試驗成績)手動輸入為陣列:
struct 科目 {
var 名稱: String
var 成績: Double
}

let 第一次平時試驗: [科目] = [
科目(名稱: "修身", 成績: 95),
科目(名稱: "體育", 成績: 65),
科目(名稱: "國文", 成績: 90),
科目(名稱: "日文", 成績: 80),
科目(名稱: "英文", 成績: 95),
科目(名稱: "解析幾何", 成績: 60),
科目(名稱: "物理", 成績: 55),
科目(名稱: "歷史", 成績: 87),
科目(名稱: "地理", 成績: 90),
科目(名稱: "勞作", 成績: 75),
科目(名稱: "圖畫", 成績: 60),
科目(名稱: "音樂", 成績: 90),
科目(名稱: "習字", 成績: 75)
]

第二步畫出統計圖,要記得先 import Charts (複數名詞)導入框架,以便取用其中的 Chart (單數名詞)物件。Chart 用法與視圖容器類似,差別是匿名函式 { } 裡面不放其他視圖,而是點(PointMark)、線(LineMark)或直條(BarMark)…等標示,具體程式碼如下:
import SwiftUI
import Charts

struct 初三某學生: View {
var body: some View {
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
BarMark(
x: .value("科目", 學科.名稱),
y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
}
}

以上透過ForEach讀取陣列13個學科成績,每科成績以直條(BarMark)畫出來構成長條圖,然後再用 RuleMark 畫一條及格線。

畫出的長條圖如下,X軸顯示科目名稱,Y軸是成績,除了13直條(Bar),還有一條60分及格線(Rule)。圖中Y軸的刻度、數值範圍、背景格線、色彩、字體大小等,都是 Chart 自動產生的:


注意這裡 BarMark() 的x軸、y軸參數,並不是簡單的數值,而是一個新類型,稱為 PlottableValue,.value() 是 PlottableValue 的類型方法,至少需要2個參數:(1)文字標籤 (2) 可標示值。
BarMark(
x: .value("科目", 學科.名稱),
y: .value("成績", 學科.成績))

第1個參數「文字標籤」是用來註解第2個參數「可標示值」,「可標示值」才是顯示在 Chart 統計圖裡面的資料,有三種基本類型:

1. String(字串) — 可當做「分類軸」,如上圖X軸的「學科.名稱」
2. Date(時間) — 可當做「時間軸」
3. Double(實數) — 可當做「數據軸」,如上圖Y軸的「學科.成績」

完整程式碼如下:
// 4-12a. Charts with SwiftUI (1)Bar Chart
// Created by Heman, 2022/11/08
import SwiftUI
import Charts

struct 科目 {
var 名稱: String
var 成績: Double
}

let 第一次平時試驗: [科目] = [
科目(名稱: "修身", 成績: 95),
科目(名稱: "體育", 成績: 65),
科目(名稱: "國文", 成績: 90),
科目(名稱: "日文", 成績: 80),
科目(名稱: "英文", 成績: 95),
科目(名稱: "解析幾何", 成績: 60),
科目(名稱: "物理", 成績: 55),
科目(名稱: "歷史", 成績: 87),
科目(名稱: "地理", 成績: 90),
科目(名稱: "勞作", 成績: 75),
科目(名稱: "圖畫", 成績: 60),
科目(名稱: "音樂", 成績: 90),
科目(名稱: "習字", 成績: 75)
]

struct 初三某學生: View {
var body: some View {
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
BarMark(
x: .value("科目", 學科.名稱),
y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(初三某學生())


Chart 統計圖內容目前可以放6種類型的標示(“Mark”):

1. BarMark (長條圖)
2. LineMark (折線圖)
3. AreaMark (區域圖)
4. PointMark (散佈圖)
5. RectangleMark (方塊圖)
6. RuleMark (水平線或垂直線)

除了RuleMark只要一個軸參數,其他都需要x, y軸參數,用法類似。利用同一組數據,更改標示物件即可畫出其他類型統計圖:


程式碼如下:
// 4-12b. Charts with SwiftUI (2)All Marks
// Created by Heman, 2022/11/08
import SwiftUI
import Charts

struct 科目 {
var 名稱: String
var 成績: Double
}

let 第一次平時試驗: [科目] = [
科目(名稱: "修身", 成績: 95),
科目(名稱: "體育", 成績: 65),
科目(名稱: "國文", 成績: 90),
科目(名稱: "日文", 成績: 80),
科目(名稱: "英文", 成績: 95),
科目(名稱: "解析幾何", 成績: 60),
科目(名稱: "物理", 成績: 55),
科目(名稱: "歷史", 成績: 87),
科目(名稱: "地理", 成績: 90),
科目(名稱: "勞作", 成績: 75),
科目(名稱: "圖畫", 成績: 60),
科目(名稱: "音樂", 成績: 90),
科目(名稱: "習字", 成績: 75)
]

struct 初三某學生: View {
var body: some View {
HStack {
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
BarMark(x: .value("科目", 學科.名稱), y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
LineMark(x: .value("科目", 學科.名稱), y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
}
HStack {
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
AreaMark(x: .value("科目", 學科.名稱), y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
PointMark(x: .value("科目", 學科.名稱), y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
}
HStack {
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
RectangleMark(x: .value("科目", 學科.名稱), y: .value("成績", 學科.成績))
}
RuleMark(y: .value("及格線", 60))
} .padding()
Chart {
ForEach(第一次平時試驗, id: \.名稱) { 學科 in
RuleMark(y: .value("成績", 學科.成績))
}
// RuleMark(y: .value("及格線", 60))
} .padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(初三某學生())


💡 註解
  1. Chart 中文是「圖」「圖表」的意思,與 diagram, graph 意思相近,常表示為統計圖表,除了長條圖(Bar chart)、折線圖(Line chart)、點散佈圖(Scatter chart)、柱狀圖(Column chart)、區域圖(Area chart)之外,還有餅狀圖(Pie chart)、燭狀圖(Candlestick chart)、雷達圖(Radar chart)…等,非常多樣。
  2. 文德女中是中國最早的女子中學之一,1920年由美國基督教北美長老會所創辦,民國31年抗戰期間,山東青島屬於淪陷區,能維持正常上課,非常不容易。
  3. PlottableValue 字面意思是「可畫出的值」,Plot 是動詞「畫出」「描繪」「標示」,Plottable 是「可描繪的」。
  4. RuleMark 的 Rule 在此是名詞「直尺」「尺度」的意思。
4-12c 環境部(環保署)Open Data: 空氣品質

統計圖表的來源是資料(或數據),若沒有資料,就不可能憑空產生圖表,但資料從何而來呢?有很多資料是透過 Open API 網路分享,或來自政府開放資料(Open Data),我們這一節就試用環境部(環保署)的空氣品質資料,來製作 Chart 統計圖表。

許多程式初學者對資料(Data)與資訊(Information)分不清楚,或許會問為什麼大學電腦相關科系要叫「資訊工程」(Information Engineering)而不是「資料工程」(Data Engineering)呢?畢竟程式要處理的是資料啊。

以「物聯網」(Internet of Things, IoT)技術為例,透過小小的傳感器(sensor)可偵測收集許多數據,例如運動手錶的傳感器可偵測心跳、血氧、跑步速度等;環境部(環保署)也同樣利用傳感器偵測空氣中的一氧化碳、臭氧、硫化物、PM2.5等有害物質濃度。

傳感器通常24小時不斷收集資料,若傳感器每秒收集100筆資料,一天24小時數據量就高達864萬筆,這麼多數據分析的結果,可能只告訴你一個資訊:「今天一切正常」!這就是資料與資訊的差別,對人而言,資訊才是有意義的。

所以寫程式之前,我們要先想好:要從空氣品質資料中,提煉出什麼資訊呢?在範例中,筆者想知道自己住家附近的空氣品質如何,以及台灣北、中、南部的空氣品質有何差異。

第一步先進行初步連接Open Data,試試看能否順利抓到資料。筆者住在新北市新店區,根據新店空氣品質的資料說明,提供了資料連接網址與公用金鑰,先用瀏覽器連接該網址(使用JSON Viewer外掛程式):

根據顯示的JSON格式,就可定義 struct 資料類型如下:
struct EPA {
let fields: [欄位]
let resource_id: String
let __extras: 金鑰
let include_total: Bool
let total: String
let resource_format: String
let limit: String
let offset: String
let _links: 連結
let records: [記錄]
}

struct 金鑰 {
let api_key: String
}

struct 欄位 {
let id: String
let type: String
let info: 標籤
}

struct 標籤 {
let label: String
}

struct 連結 {
let start: String
let next: String
}

struct 記錄 {
let siteid: String // 測站編號
let sitename: String // 測站名稱
let monitordate: String // 監測日期
let itemname: String // 測項名稱
let itemengname: String // 測項英文名稱
let itemunit: String // 測項單位
let concentration: String // 數值
}


接下來就如同在第10課(4-10a)芝加哥藝術博物館用過的解碼過程,用JSONDecoder()取得資料記錄(因為用到JSONDecoder,上面的 struct 定義均需加上 Codable 規範):
import Foundation

let 網址 = "https://data.moenv.gov.tw/api/v2/aqx_p_192?api_key=e8dd42e6-9b8b-43f8-991e-b3dee723a52d&limit=144&format=JSON"
var 記錄表: [記錄] = []
Task {
if let myURL = URL(string: 網址) {
do {
let (data, error) = try await URLSession.shared.data(from: myURL)
let 解碼結果 = try JSONDecoder().decode(EPA.self, from: data)
記錄表 = 解碼結果.records
print(解碼結果)
} catch {
print("有問題:\(error)")
}
}
}


合起來完整的程式如下,其中用 List 視圖顯示抓到的資料記錄表:
// 4-12c 環境部(環保署)Open Data: 新店空氣品質
// Created by Heman, 2022/11/06
// Updated by Heman, 2023/08/22: epa.gov.tw -> moenv.gov.tw
// https://data.moenv.gov.tw/swagger/
import SwiftUI

let 網址 = "https://data.moenv.gov.tw/api/v2/aqx_p_192?api_key=e8dd42e6-9b8b-43f8-991e-b3dee723a52d&limit=144&format=JSON"

struct EPA: Codable {
let fields: [欄位]
let resource_id: String
let __extras: 金鑰
let include_total: Bool
let total: String
let resource_format: String
let limit: String
let offset: String
let _links: 連結
let records: [記錄]
}

struct 金鑰: Codable {
let api_key: String
}

struct 欄位: Codable {
let id: String
let type: String
let info: 標籤
}

struct 標籤: Codable {
let label: String
}

struct 連結: Codable {
let start: String
let next: String
}

struct 記錄: Codable, Equatable, Hashable {
let siteid: String // 測站編號
let sitename: String // 測站名稱
let monitordate: String // 監測日期
let itemname: String // 測項名稱
let itemengname: String // 測項英文名稱
let itemunit: String // 測項單位
let concentration: String // 數值
}

struct 空氣品質: View {
@State var 記錄表: [記錄] = []
var body: some View {
if 記錄表 == [] {
Text("等候資料下載...")
.task {
if let myURL = URL(string: 網址) {
do {
let (data, error) = try await URLSession.shared.data(from: myURL)
let 解碼結果 = try JSONDecoder().decode(EPA.self, from: data)
記錄表 = 解碼結果.records
print(解碼結果)
} catch {
print("有問題:\(error)")
}
}
}
} else {
List(記錄表, id: \.self) { 資料 in
Text("\(資料.sitename) \(資料.monitordate) \(資料.itemengname) \(資料.concentration) \(資料.itemunit)")
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(空氣品質())




💡 註解
  1. 連接環保署Open Data須申請金鑰(API Key),金鑰每年更新一次,若此範例程式無法取得資料,請檢查網站,或參考官網說明文件申請自己的金鑰(免費用、免審核)。

  2. 自2023年8月22日起,環保署升格為環境部,網域名稱由 epa.gov.tw 改為 moenv.gov.tw,其餘參數不變。
4-12d 環境部(環保署)Open Data: 空氣品質 (2)

上一節其實我們並未得到有用資訊,雖然有PM2.5、PM10、一氧化碳、二氧化氮、二氧化硫、臭氧等6種資料,但從這些資料本身並無法看出空氣品質的好壞,因為還缺乏數據的比較基準。

好壞經常是相對的,須經過比較。另一方面,空氣品質隨時在變化,累積一段時間才知道趨勢變化。因此,原始資料常常需要加上時間、地點或其他方面的因素,才能萃取出有用的資訊。

這節我們想比較台灣北、中、南的空氣品質,我們只取其中 PM2.5來比較,因為在上述6種有害物質中,PM2.5最重要。根據環境部(環保署)的標準,PM2.5基準數值為:

- 15.5(μg/m3)以下 — 良好
- 15.5~35.5 — 普通
- 35.5以上 — 對身體有不良影響

北、中、南我們分別取新北土城、彰化線西、高雄林園三個工業區,以最近一週的PM2.5資料來比較,統計圖中畫兩條基準線:15.5, 35.5,從統計圖可以看出,北部(藍色區塊)大多良好、中部(綠色線形)、南部(橘點)大多普通。


以下為完整的程式碼,其中 struct 我們省略不需要的欄位:
// 4-12d 環境部(環保署)空氣品質資料
// Created by Heman, 2022/11/06
// Updated by Heman, 2023/08/22 epa.gov.tw -> moenv.gov.tw
// https://data.moenv.gov.tw/swagger/
import SwiftUI
import Charts

struct EPA: Codable {
let fields: [欄位]
let total: String
let _links: 連結
let records: [記錄]
}

struct 欄位: Codable, Equatable {
let id: String
let type: String
let info: 標籤
}

struct 標籤: Codable, Equatable {
let label: String
}

struct 連結: Codable, Equatable {
let start: String
let next: String
}

struct 記錄: Codable, Equatable, Hashable {
let siteid: String // 測站編號
let sitename: String // 測站名稱
let monitordate: String // 監測日期
let itemname: String // 測項名稱
let itemengname: String // 測項英文名稱
let itemunit: String // 測項單位
let concentration: String // 數值
}

func 抓取資料(_ 網址: String) async throws -> [記錄] {
var 最後結果: [記錄] = []
if let myURL = URL(string: 網址) {
let (data, error) = try await URLSession.shared.data(from: myURL)
let 解碼結果 = try JSONDecoder().decode(EPA.self, from: data)
let pm25 = 解碼結果.records.filter { x in
x.itemengname == "PM2.5"
}
最後結果 = pm25.sorted { a, b in
a.monitordate < b.monitordate
}
print(解碼結果.total, 最後結果.count)
}
return 最後結果
}

var 格式 = DateFormatter()
格式.dateFormat = "yyyy-MM-dd HH:mm"

struct 空氣品質: View {
@State var 新北: [記錄] = []
@State var 彰化: [記錄] = []
@State var 高雄: [記錄] = []
let 土城網址 = "https://data.moenv.gov.tw/api/v2/aqx_p_193?api_key=e8dd42e6-9b8b-43f8-991e-b3dee723a52d&limit=1000&offset=1008&format=JSON"
let 線西網址 = "https://data.moenv.gov.tw/api/v2/aqx_p_222?api_key=e8dd42e6-9b8b-43f8-991e-b3dee723a52d&limit=1000&offset=1008&format=JSON"
let 林園網址 = "https://data.moenv.gov.tw/api/v2/aqx_p_240?api_key=e8dd42e6-9b8b-43f8-991e-b3dee723a52d&limit=1000&offset=1008&format=JSON"
var body: some View {
if 新北 == [] || 彰化 == [] || 高雄 == [] {
Text("等候資料下載...")
.task {
do {
if 新北 == [] { 新北 = try await 抓取資料(土城網址) }
if 彰化 == [] { 彰化 = try await 抓取資料(線西網址) }
if 高雄 == [] { 高雄 = try await 抓取資料(林園網址) }
} catch {
print("連線有問題:\(error)")
}
}
} else {
Chart {
ForEach(新北, id: \.self) { r in
AreaMark(
x: .value("時間", 格式.date(from: r.monitordate) ?? Date()),
y: .value("PM2.5", Double(r.concentration) ?? 0.0))
.foregroundStyle(by: .value("地點", "新北"))
}
ForEach(彰化, id: \.self) { r in
LineMark(
x: .value("時間", 格式.date(from: r.monitordate) ?? Date()),
y: .value("PM2.5", Double(r.concentration) ?? 0.0))
.foregroundStyle(by: .value("地點", "彰化"))
}
ForEach(高雄, id: \.self) { r in
PointMark(
x: .value("時間", 格式.date(from: r.monitordate) ?? Date()),
y: .value("PM2.5", Double(r.concentration) ?? 0.0))
.foregroundStyle(by: .value("地點", "高雄"))
}
RuleMark(y: .value("良好", 15.5))
.foregroundStyle(Color.green)
RuleMark(y: .value("普通", 35.5))
.foregroundStyle(Color.yellow)
} .padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(空氣品質())


💡 註解
  1. PM2.5 的基準數值參考自「臺灣公衛學生聯合會」網站 -- 科普小學堂|空氣中的危害-細懸浮微粒PM2.5
  2. 自2023年8月22日起,環保署升格為環境部,網域名稱由 epa.gov.tw 改為 moenv.gov.tw,其餘參數不變。
第13課 資料表(TabularData)

提到統計圖表,這幾年疫情期間大家一定看過新冠肺炎(COVID-19)各國確診數或死亡數的統計,這些統計數據最權威的來源之一,就是美國約翰霍普金斯大學(Johns Hopkins University),統計數據從2020年1月開始每天更新並公布在網路上:
https://github.com/CSSEGISandData/COVID-19

統計的原始資料是一個 CSV 檔,CSV 代表 Comma Separated Values (以逗號分隔的數值),是一種簡單的檔案格式(副檔名 .csv),內容是單純文字(plain text),可導入到 Office 軟體的 Excel 表單,也很方便用程式讀取。網址如下,可以用瀏覽器打開看看:
https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_deaths_global.csv


有了這些數據,就能用上一課學的 Charts 畫出統計圖。但是要如何讀取遠端的CSV檔呢?TabularData 框架就是用來處理表格結構的資料,其中的 DataFrame 物件可讀取CSV或JSON格式的檔案,DataFrame 用法非常簡單,只要一行程式碼就能搞定:
新冠肺炎死亡數 = try DataFrame(contentsOfCSVFile: myURL)

直接就能下載 myURL 所在網址的CSV檔案。以下範例程式會將下載的DataFrame列印到主控台:
// 4-13a TabularData: COVID-19 死亡統計 (Johns Hopkins University)
// Created by Heman, 2022/11/29
// Data Source -- https://github.com/CSSEGISandData/COVID-19
import TabularData
import Foundation

let 網址_死亡數 = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_deaths_global.csv"

var 新冠肺炎死亡數: DataFrame = DataFrame()

if let myURL = URL(string: 網址_死亡數) {
do {
新冠肺炎死亡數 = try DataFrame(contentsOfCSVFile: myURL)
} catch {
print("無法下載CSV檔:\(error)")
}
}

print(新冠肺炎死亡數)


主控台顯示以下結果,可以看出 DataFrame 結構幾乎跟 Excel 表單一樣:
┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳╍╍╍╍╍╍┓
┃ ┃ Province/State ┃ Country/Region ┃ Lat ┃ Long ┃ 1/22/20 ┃ 1/23/20 ┃ 1/24/20 ┃ 1/25/20 ┃ 1/26/20 ┃ 1/27/20 ┃ 1/28/20 ┃ 1/29/20 ┃ 1/30/20 ┃ 1/31/20 ┃ 2/1/20 ┃ 1031 ┇
┃ ┃ <String> ┃ <String> ┃ <Double> ┃ <Double> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ <Int> ┃ more ┇
┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇╍╍╍╍╍╍┩
│ 0 │ nil │ Afghanistan │ 33.93911 │ 67.709953 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 1 │ nil │ Albania │ 41.1533 │ 20.1683 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 2 │ nil │ Algeria │ 28.0339 │ 1.6596 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 3 │ nil │ Andorra │ 42.5063 │ 1.5218 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 4 │ nil │ Angola │ -11.2027 │ 17.8739 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 5 │ nil │ Antarctica │ -71.9499 │ 23.347 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 6 │ nil │ Antigua and Barbuda │ 17.0608 │ -61.7964 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 7 │ nil │ Argentina │ -38.4161 │ -63.6167 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 8 │ nil │ Armenia │ 40.0691 │ 45.0382 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 9 │ Australian Capital Territory │ Australia │ -35.4735 │ 149.0124 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 10 │ New South Wales │ Australia │ -33.8688 │ 151.2093 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 11 │ Northern Territory │ Australia │ -12.4634 │ 130.8456 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 12 │ Queensland │ Australia │ -27.4698 │ 153.0251 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 13 │ South Australia │ Australia │ -34.9285 │ 138.6007 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 14 │ Tasmania │ Australia │ -42.8821 │ 147.3272 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 15 │ Victoria │ Australia │ -37.8136 │ 144.9631 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 16 │ Western Australia │ Australia │ -31.9505 │ 115.8605 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 17 │ nil │ Austria │ 47.5162 │ 14.5501 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 18 │ nil │ Azerbaijan │ 40.1431 │ 47.5769 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
│ 19 │ nil │ Bahamas │ 25.025885 │ -78.035889 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ ┆
┢╍╍╍╍┷╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍╍╍┷╍╍╍╍╍╍┪
┇ ... ┇
┗╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┛
289 rows, 1,046 columns


像這樣的表格資料可稱為「結構化資料」,一般我們常聽到的「資料庫」(Database)就是由結構化的資料表(Table)所組成,一個資料表可分解成若干「記錄」(Record),也就是表格橫向的一列(Row),每筆記錄再分解成最基本的「欄位」(Field)。

上面這個新冠肺炎死亡統計,第1個欄位名稱是 Province/State,相當於省或州,第2個欄位 Country/Region 則是國家或地區,第3欄位 Lat (緯度)、第4欄位 Long (經度)代表地理位置,第5欄位之後是日期,”1/22/20” 是美式寫法,表示「1月22日, 2020年」。

最後一行印出 "289 rows, 1,046 columns",表示整個表格有 289列(相當於289筆記錄),1046個欄位。

接下來如何將這表格資料用 Charts 畫出來呢?下節將進一步學習如何處理 DataFrame 資料表。

💡 註解

  1. Tabular 是 table (表格)的形容詞,TabularData 意即表格式的資料,也就是結構化資料;DataFrame 含意與資料表(data table)無異,在此 frame (框架)意思與 table (表格)接近,形容一格一格的儲存格所構成的集合。
CUNNING

看官方文件好像挺多功能的TabularData,網路上教學倒是很少,好像只能用在csv跟json

2022-12-24 20:37
4-13b 台灣確診統計圖(動畫)

上一節透過 DataFrame 讀進來的資料表,基本結構與 Excel 表單相同,水平一列稱為 Row,垂直一行稱為 Column,最上方一列是每個Column的欄位名稱,最左邊則有每一列(每筆記錄)的索引,如下圖所示:


如何讀取某一格的資料呢?假設這個表單名為「確診統計表」,讀取某個儲存格的程式碼如下:
let x = 確診統計表[column: 0][12]          // Optional("Queensland")
let y = 確診統計表[row: 12]["1/22/20"] // Optional(0)
let z = 確診統計表["Country/Region"][12] // Optional("Australia")

也就是將DataFrame 資料表當做一個二維陣列,利用X-Y平面座標,X軸用整數索引0, 1, 2…或「欄位名稱」來定位,欄位名稱一定是字串類型;垂直Y軸沒有名稱,只能用整數索引0, 1, 2…定位。

讀取 DataFrame 儲存格資料要注意兩點:
  1. 由於CSV檔內容並無欄位的「資料類型」,因此各欄資料類型都是自動判斷的,有時須改用人工設定。
  2. 讀取儲存格的結果一律都是 Optional,因為儲存格內未必有值,所以必要時須強制取值(!)或用 ?? 提供預設值。

例如下面範例中,關鍵的一行程式碼為:
let 某日累計 = 台灣確診統計[日期字串].first as! Int ?? 0

從統計表取出某一天[日期字串]得到一行(Column)資料,再取其第一個([0]或.first)儲存格,然後強制設定為整數類型(as! Int),若無資料,則預設值(??)為 0。

知道 DataFrame 如何操作之後,接下來就可以用台灣的確診資料來做 Charts 統計圖。我們希望用動畫的方式,呈現每天新增的確診數(注意約翰霍普金斯大學的原數據是累計確診數,要減去前一天的累計值),可以先看看最後執行結果:

程式第一步,先取得台灣的確診數據。下載約翰霍普金斯大學的全球確診資料表之後,利用 DataFrame 的過濾 .filter() 方法,注意 filter() 第2個參數,String.self 用來指定該行(Column)的資料類型,後面送入匿名函式的參數「國家」才能做運算:
全球確診統計 = try DataFrame(contentsOfCSVFile: myURL)
let 台灣 = 全球確診統計.filter(on: "Country/Region", String.self) { 國家 in
國家 == "Taiwan*"
}
台灣確診統計 = DataFrame(台灣)

接下來讀取「台灣確診統計」的儲存格資料,轉存到陣列「台灣確診日報表」,以便繪製長條圖:
struct 日報表 {
let date: Date
let number: Int
}
var 台灣確診日報表: [日報表] = []
var 前一天累計 = 0
var 日期字串 = "1/22/20"

let 某日累計 = 台灣確診統計[日期字串].first as! Int ?? 0
台灣確診日報表.append(日報表(date: 某日, number: 某日累計 - 前一天累計))
前一天累計 = 某日累計

這裡需要將「日期字串(”1/22/20”)」轉換成 Date 類型,並且前進到下一日(”1/23/20”),怎麼做呢?還記得我們在第1單元第10課學過的 Date, DateFormatter, Calendar 三物件嗎?DateFormatter 可以讓我們指定日期與時間的字串格式,並且可雙向轉換:
var 日期格式 = DateFormatter()
日期格式.dateFormat = "M/d/yy"
日期格式.timeZone = .gmt

let 某日 = 日期格式.date(from: 日期字串) ?? Date() // String -> Date
let 隔日 = 某日.addingTimeInterval(86400)
日期字串 = 日期格式.string(from: 隔日) ?? "" // Date -> String

注意這裡若改成: 日期格式.dateFormat = "MM/dd/yy",轉成字串會變成 “01/22/20”,不足2位數會自動補0,但這與原始資料不符,故必須用 “M/d/yy”。dateFormat 各字母含意如下,字母個數對應整數位數:
● y — 年(0-9)
● M — 月(1-12)
● d — 日(1-31)
● h — 時(0-23)
● m — 分(0-59)
● s — 秒(0-59)

接下來,將每一天的確診數據,利用定時器(Timer)逐次加入「台灣確診日報表」陣列之後,很容易就能做出動態的統計圖了:
Chart {
ForEach(台灣確診日報表, id: \.date) { 每日 in
BarMark(x: .value("日期", 每日.date), y: .value("確診數", 每日.number))
}
}

以下是完整程式碼:
// 4-13b 台灣COVID-19確診統計
// Created by Heman, 2022/12/01
import SwiftUI
import Charts
import TabularData

let 網址_確診數 = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_confirmed_global.csv"

var 日期格式 = DateFormatter()
日期格式.dateFormat = "M/d/yy"
日期格式.timeZone = .gmt

struct 日報表 {
let date: Date
let number: Int
}

struct 台灣確診統計: View {
@State var 台灣確診統計 = DataFrame()
@State var 台灣確診日報表: [日報表] = []
@State var 日期字串 = "1/22/20"
@State var 前一天累計 = 0
@State var 統計最後一日 = Date()
let 定時 = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
Text("台灣新冠肺炎確診人數(每日)")
.font(.title3)
.padding()
.onAppear {
var 全球確診統計 = DataFrame()
if let myURL = URL(string: 網址_確診數) {
do {
全球確診統計 = try DataFrame(contentsOfCSVFile: myURL)
let 台灣 = 全球確診統計.filter(on: "Country/Region", String.self) { 國家 in
國家 == "Taiwan*"
}
台灣確診統計 = DataFrame(台灣)
if let 最後一天 = 台灣確診統計.columns.last?.name {
統計最後一日 = 日期格式.date(from: 最後一天) ?? Date()
}
print("Last Date: \(統計最後一日)")
} catch {
print("無法轉換下載CSV檔:\(error)")
}
}
}
VStack(alignment: .trailing) {
Chart {
ForEach(台灣確診日報表, id: \.date) { 每日 in
BarMark(x: .value("日期", 每日.date), y: .value("確診數", 每日.number))
}
}
.onReceive(定時) { _ in
let 某日 = 日期格式.date(from: 日期字串) ?? Date()
if 某日 <= 統計最後一日 {
let 某日累計 = 台灣確診統計[日期字串].first as! Int ?? 0
台灣確診日報表.append(日報表(date: 某日, number: 某日累計 - 前一天累計))
前一天累計 = 某日累計
let 隔日 = 某日.addingTimeInterval(86400)
日期字串 = 日期格式.string(from: 隔日) ?? ""
}
}
Text("↑\n\(日期字串)")
.multilineTextAlignment(.trailing)
}
.padding()
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(台灣確診統計())

輸出的統計圖如下:


💡 註解
  1. Column 的原意是「柱子」,古希臘建築特色之一就是有很多石柱,稱為 Column,有三種柱頭形式。
  2. 中文的行、列並沒有特定方向,但在此我們習慣用「直行」「橫列」,以對應Column及Row。
  3. 打開任一個Excel表單,最上方橫列都會有 A, B, C, D… 編碼,相當於X軸座標;最左邊直行有1, 2, 3, 4… 編號,相當於Y軸座標。所以左上方第一個儲存格座標就是A1,座標C5則指到第3行、第5列的儲存格。
  4. 如何將統計圖右下角 Text("↑\n\(日期字串)") 改成台灣習慣的 “2020/01/22” 格式呢?
CUNNING

直拉改會卡住,再加一個日期格式2.dateFormat = "yyyy/MM/dd"可以

2022-12-25 0:03
雪白西丘斯

沒錯,註解4的正確答案。下一節就有用到

2022-12-31 22:16
4-13c 新冠肺炎(COVID-19)全球排名

上一節學會如何讀取 DataFrame 個別儲存格資料,並將一筆記錄(record),也就是水平的一列(row),轉化為 Chart 統計圖之後;本節再接著學習如何處理垂直的一行行數據。

程式目標是讀取新冠肺炎統計表當中的國家(”Country/Region”)與日期(如”1/22/20”)兩行資料,根據日期欄位的確診數據加以排序,選出前若干名做成統計圖,並隨著日期變化,呈現動畫效果。

我們已知如何用 DataFrame 讀取網路上的CSV檔案,只要一行程式碼:
全球確診統計 = try DataFrame(contentsOfCSVFile: myURL)

接下來,如何從龐大資料表 DataFrame 選取其中若干欄位(Column)呢?也是非常簡單,DataFrame 物件提供很多操作 Row 與 Column 的方法,其中 selecting() 可用來選取所需的 Column,只要將「欄位名稱」加入參數的陣列中即可:
let 字串 = 日期格式.string(from: 日期)
let 某日報表 = 全球確診統計.selecting(columnNames: ["Country/Region", 字串])

其中「字串」已從日期轉換成 “M/d/yy” 的格式,在上一節談過。

接下來會碰到一個難題,若仔細觀察上一節原始資料表,會發現有些幅員較大的國家,如澳洲、美國、中國…等,數據是依照省或州("Province/State")來統計的,如何加總、合併同一國家的數據呢?所幸 DataFrame 也提供了兩個方法 grouped(), sums() 可用:
let 字串 = 日期格式.string(from: 日期)
let 某日報表 = 全球確診統計
.selecting(columnNames: ["Country/Region", 字串])
.grouped(by: "Country/Region")
.sums(字串, Int.self, order: .descending)

grouped() 可按照相同的「欄位名稱」分組,然後用 sums() 對日期欄位的數據加總、合併、排序,這樣多寫兩行就搞定了。

注意上面程式連續用三個物件方法,像不像視圖修飾語(View Modifier)的語法?沒錯,這是 Swift 程式語言的特色之一,物件方法可以一個套一個,非常直覺又簡單,背後概念就如同本單元第10課(4-10c)提過的「模組化」,每個物件方法(或修飾語)如同一個程式模塊,前面一個的輸出,恰可當做下一個的輸入:


接下來我們將以上功能寫成函式,輸入「日期參數」,輸出結果是排序後的國家、確診數的陣列:
雪白西丘斯

抱歉,Mobile01 對某些字元過敏(找好久才找出來),不得已分成兩段

2022-12-09 19:05
struct 排名記錄 {
var 國家: String = ""
var 確診數: Int = 0
var 日期: Date = Date()
}

func 取得某日排名(_ 日期參數: Date) -> [排名記錄] {
var 結果: [排名記錄] = []
let 字串 = 日期格式.string(from: 日期參數)
let 某日報表 = 全球確診統計
.selecting(columnNames: ["Country/Region", 字串])
.grouped(by: "Country/Region")
.sums(字串, Int.self, order: .descending)
for i in 0..<topN {
let 名次 = 排名記錄(
國家: 某日報表["Country/Region"][i] as! String,
日期: 日期參數,
確診數: 某日報表["sum(\(字串))"][i] as! Int)
結果.append(名次)
}
return 結果
}

其他部分仿照上一節的寫法,將陣列送入 Chart 畫出長條圖,並利用定時器每0.1秒增加一天,繪製動態的統計圖,直到表格的最後一日為止。完整程式碼如下:
// 4-13c 新冠肺炎(COVID-19)全球排名(TabularData + Charts)
// Created by Heman, 2022/12/05
import SwiftUI
import TabularData
import Charts

let 網址_確診數 = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_confirmed_global.csv"

var 日期格式 = DateFormatter()
日期格式.dateFormat = "M/d/yy"
日期格式.timeZone = .gmt

var 輸出格式 = DateFormatter()
輸出格式.dateFormat = "yyyy/MM/dd"
輸出格式.timeZone = .gmt

struct 排名記錄 {
var 國家: String = ""
var 確診數: Int = 0
var 日期: Date = Date()
}

struct 全球確診排名: View {
@State var 全球確診統計 = DataFrame()
@State var 統計最後一日 = Date()
@State var 某日 = Date()
@State var 某日排名: [排名記錄] = []
let 統計第一天 = "1/22/20"
let topN = 19
let 定時器 = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()

func 取得某日排名(_ 日期參數: Date) -> [排名記錄] {
var 結果: [排名記錄] = []
let 字串 = 日期格式.string(from: 日期參數)
let 某日報表 = 全球確診統計
.selecting(columnNames: ["Country/Region", 字串])
.grouped(by: "Country/Region")
.sums(字串, Int.self, order: .descending)
for i in 0..<topN {
let 名次 = 排名記錄(
國家: 某日報表["Country/Region"][i] as! String,
確診數: 某日報表["sum(\(字串))"][i] as! Int,
日期: 日期參數)
結果.append(名次)
}
return 結果
}

var body: some View {
Text("COVID-19 Confirmed Cases\n2020/01/22-\(輸出格式.string(from: 某日))")
.font(.title3)
.multilineTextAlignment(.center)
.padding()
.onAppear {
if let myURL = URL(string: 網址_確診數) {
do {
全球確診統計 = try DataFrame(contentsOfCSVFile: myURL)
if let 最後一天 = 全球確診統計.columns.last?.name {
統計最後一日 = 日期格式.date(from: 最後一天) ?? Date()
}
print("Last Date: \(統計最後一日)")
} catch {
// fatalError("無法下載CSV檔")
print("無法下載CSV檔:\(error)")
}
}
某日 = 日期格式.date(from: 統計第一天) ?? Date()
某日排名 = 取得某日排名(某日)
}
Chart {
ForEach(某日排名, id: \.日期) { 某日 in
BarMark(
x: .value("確診數", 某日.確診數),
y: .value("國家", 某日.國家))
.annotation(position: .overlay, alignment: .trailing) {
Text("\(某日.確診數)")
}
}
}
.padding()
.onReceive(定時器) { _ in
if 某日 < 統計最後一日 {
某日 = 某日.addingTimeInterval(86400)
某日排名 = 取得某日排名(某日)
} else {
PlaygroundPage.current.finishExecution()
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(全球確診排名())

以下是執行結果:


💡 註解

1. 作業:GitHub上面還有其他資料集(Data Set),如世界人口統計,請仿照本課內容,做出類似的統計圖。
-- Core Datasets https://github.com/orgs/datasets/repositories
-- World Population Dataset (CSV)
雪白西丘斯

第四單元補充的部份,到此告一段落,接下來將準備第五單元AI程式的入門課程。

2022-12-09 19:15
  • 8
內文搜尋
X
評分
評分
複製連結
請輸入您要前往的頁數(1 ~ 8)
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?