Cover

手上的資料格式跟圖表格格不入,總要處理半天才搞定嗎?當我們在利用 d3.js 做視覺化時,常碰到需要把資料轉換成各種不同結構的時候,因此 d3.js 其實提供了相當多方便的轉換函式,這次就讓我們來一次做個總整理吧!

資料結構是資訊相關科系的必修課程,雖然是如此的專業科目,既然稱作「資料」結構,便與需要操作資料的我們都脫不了關係,無論你是要做行銷、商業智慧或是新聞專題。想要轉換一整排的資料?這就叫陣列。表現候選人之間的關係?你要使用有向圖。總預算款項目節的階層表現?樹狀結構是你的好朋友。資料透過精巧的轉換變成適當的資料結構,才能順利套入視覺化的框架之中。

雖然說一般的圖表大多是使用陣列式的資料,我們難免還是要根據圖表規格來轉換資料格式與做基本的統計分析,例如 D3.js 的經典泡泡圖背後的 Pack Layout 結構,其實就是一種樹狀的資料結構;而為了讓我們能精準的控制資料範圍,座標、半徑等維度也必需要經過適當的轉換。

 

 

我們可以自己撰寫程式來做資料的轉換,但這樣的程式碼通常重覆性都很高、而且也很繁瑣。於是, D3.js 變提供了一整組資料處理的函式集,讓資料整理變得再容易不過了!

陣列操作概論 — 使用 d3 Array 函式集

如前所述,陣列是我們最常用到的資料結構,我們可以利用 d3.extent 算出陣列中的極值:

  d3.extent([1,2,3,4,5])   // 執行結果為 [1,5]

這相當於各別呼叫 d3.min 與 d3.max:

  [d3.min([1,2,3,4,5]), d3.max([1,2,3,4,5])]

陣列的極值界定了資料的範圍,一個常見的使用情境為做螢幕座標的對應,我們可以使用 d3.scale.linear 的轉換函式:

  d3.scale.linear().domain(d3.extent([1,2,3,4,5]).range([0,800])

上面程式碼所傳回的函式可以替我們將 1 ~ 5 的範圍轉換到 0 ~ 800 ,很適合用來做圖表的 X 軸座標轉換。

基本陣列我們可以這樣操作,但常常我們讀入的資料會是以物件陣列的形式表現,例如:

  var scores = [
    {name: "James", score: 90},
    {name: "Jimmy", score: 85},
    {name: "Jean", score: 72},
    {name: "Apple", score: 78}
  ];

上面這個陣列中共有三個物件,每個對應到了一個學生姓名與他的成績。這時候 d3.extent 需要再多附帶一個 accessor 函式來取得我們想要計算極值的欄位:

  d3.extent(scores, function(it) { return it.score; });

物件化:使用 d3.map

使用 accessor 函式取值是個很常見的模式。例如,我們可以利用 d3.map 函式將陣列轉換成物件形態:

  var scoremap = d3.map(scores, function(it) { return it.name; });

這時原本的陣列便會轉換成類似如下的形式:

  {
    "James": {name: "James", score: 90},
    "Jimmy": {name: "Jimmy", score: 85},
    "Jane":  {name: "Jean", score: 72},
    "Apple": {name: "Apple", score: 78}
  }

( 注: 事實上傳回的會是一個 d3.map 物件, 上例為其物件形態的示意結構。 )

光是這樣,也許你會覺得沒什麼用處;事實上這至少有兩個好處:

  1. 我們只要知道學生姓名,便能取得他的成績,例如: scoremap.get(“James") 即能取得 James 的成績。
  2. 由於每個名字只能對應到一筆資料,這個手法可以用來計算特定欄位中不同的資料共有幾筆。

假設我們想知道班上學生有多少個不同的姓名,使用 d3.map 搭配 keys 即可做到:

  d3.map(scores,function(it) { return it.name; }).keys();

上述函式的執行結果如下:

  ["James", "Jimmy", "Jane", "Apple"]

也因此,從物件要逆轉回陣列也就容易了,我們直接搭配 JavaScript 的內建 Array.map 函式:

  scoremap.keys().map(function(it) { return scoremap.get(it); });

這樣一行便能把 scoremap 轉換為 scores 的陣列形式。

分組:使用 d3.nest

把陣列轉換成物件,在接下來要說明的這種狀況也非常有幫助。以上面學生分數的資料為例,若我們想要把學生每隔十分做一組分組,該怎麼做呢? d3.nest 完美的解決了這個問題:

 var scoregroup = d3.nest().key(function(it) {
   return parseInt(it.score / 10);
 }).map(scores);

這段程式碼在幹嘛呢?d3.nest 可以幫我們把陣列中的元素分組合成陣列;在上例中,我們透過 key() 中的 accessor 函式告訴 d3.nest,我們要以分數的十位數做為分組單位,然後利用 map() 指定將欲處理的陣列轉換成物件,結果如下:

  {
    "7": [{name: "Jean", score: 72}, {name: "Apple", score: 78}],
    "8": [{name: "Jimmy", score: 85}],
    "9": [{name: "James", score: 90}]
  }

接著,若我們想要知道 70 分的學生有多少個,使用 JavaScript Array 內建的 length 參數即可取得:

  scoregroup["7"].length

我們甚至可以在 accessor 函式中調整欄位格式,下例產生類似 “70~79″ 這樣的欄位組名:

  d3.nest().key(function(it) {
    var base = parseInt(it.score / 10);
    return base + "0 ~ " + base + "9";
  }).map(scores);

執行結果如下:

  {
    "70~79": [{name: "Jean", score: 72}, {name: "Apple", score: 78}],
    "80~89": [{name: "Jimmy", score: 85}],
    "90~99": [{name: "James", score: 90}]
  }

不過,像這樣產生分數分組,必須要資料中原本就已經有人獲得了區間中的成績才行,像上例並沒有人得到 70 分以下的成績,也就不會產生 “60~69″ 的組別了。

若要手動產生分組,一般可能使用迴圈:

  var range = []:
  for(var idx = 0; idx < 10; idx++ ) range.push(idx);

但其實 d3.js 也提供了類似 Python range() 這樣的函式,可以快速產生一組數據:

  d3.range(0, 10, 1);

在上例中,我們要 d3.js 從 0 開始,每隔  1 產生一組數據,直到 10 為止 ( 不含 10 ),結果如下:

  [0,1,2,3,4,5,6,7,8,9]

所以,若要產生上面的分數組別,只要再利用 Array.map 稍微再動一點手腳即可:

  d3.range(0,10,1).map(function(it) { return it + "0 ~ " + it + "9"; });

d3.range 的起點與間距可以省略,預設會從 0 開始、以 1 為間隔,所以下列兩者的執行結果是等價的:

  d3.range(0,10,1);
  d3.range(10);

有時候我們想直接依照實際學生得分的範圍做分組,這時可以利用 d3.quantile 函式做快速分位。 d3.quantile 的基本語法如下:

  d3.quantile( 資料陣列, 分位值 )

背後的演算法我們不細談,只要大概想像成 d3.quantile 依我們選定的分位值 ( 0 ~ 1 ) 拿來在資料陣列中做內差取得新值即可。 假設我們想要把學生得分分為  6 組,便可以這樣做:

  var ticks = d3.range(7).map(function(it) { 
    return d3.quantile(d3.extent(scores), it/6);
  });

在上例,我們先用 d3.range 產生 [0,1,2,3,4,5,6] ;接著利用 Array.map 將這些數字做為 d3.quantile 內差用的分位值,內差出學生分數 ( d3.extent(scores) ) 內的級距。以前面的分數為例:

  • d3.extent(scores) 為 [72,90]
  • d3.quantile([72,90], 0/6) = 72
  • d3.quantile([72,90], 1/6) = 75
  • d3.quantile([72,90], 2/6) = 78
  • d3.quantile([72,90], 3/6) = 81
  • d3.quantile([72,90], 4/6) = 84
  • d3.quantile([72,90], 5/6) = 87
  • d3.quantile([72,90], 6/6) = 90

其傳回值 ticks 即為 [72,75,78,81,84,87,90] 。

Pairing — 資料快速配對

上例的級距做完以後,你可能會想問:「小編你說好要分成六組,怎麼算了七個數字出來」?其實道理很簡單,七個區間隔出來的範圍就剛好六組囉!但若我們想要把這些數字做為組別的名字,資料需要再經過組合。與 d3.range 相同,我們可以透過一個迴圈來做到,但也一樣繁瑣且重覆,這時就要使用 d3.js 所提供的 d3.pairs 函式,自動幫我們做資料配對:

  var pairs = d3.pairs([72,75,78,81,84,87,90]);
  // pairs = [[72,75],[75,78],[78,81],[84,87],[87,90]]

d3.pairs 的配對方式很簡單,他將陣列中的相鄰數字一組一組重新建立成一個陣列,這在我們需要畫線時非~常的方便!因為線是由許多點連結而成,而我們的資料通常都是點的資訊,線的資訊便得自己建立了。這時使用 d3.pairs 就方便多了:

  // 假設 points 為一個物件陣列,每個物件都有 x 與 y 屬性標明位置
  lines = d3.pairs(points);
  d3.select("svg").selectAll("line").data(lines).attr({
    x1: function(it) { return it[0].x; },
    y1: function(it) { return it[0].y; },
    x2: function(it) { return it[1].x; },
    y2: function(it) { return it[1].y; }
  });

速查摘要

看到這裡,是否一個頭兩個大呢?資料處理原本就是個很繁瑣的問題,感謝 d3.js 本身帶有的強大輔助函式,讓我們可以省去很多功夫,專心在對付視覺化上。為了讓各位的頭小一點,我們把上列提到的所有函式在這裡做一個簡單的摘要,讀者可以利用摘要做快速的參考,再透過上列的說明做深入的了解。

  • d3.extent(Array, accessor) — 計算陣列極值。傳回陣列。
  • d3.min(Array, accessor) — 計算陣列最小值
  • d3.max(Array, accessor) — 計算陣列最大值
  • d3.map(Array, accessor) — 將陣列轉換成物件,利用 accessor 傳回值做索引
  • d3.map(Array,accessor).keys() — 取得陣列中某欄位的資料有哪些不同的可能
  • d3.nest().key(accessor).map(Array) — 把陣列依 accessor 指定的索引值分組
  • d3.range(start,end,delta) — 在指定範圍與間距中產生一組資料陣列
  • d3.quantile(Array, p) — 類似內插,利用傳入的 p 值 ( 0 ~ 1 ) 與陣列決定一個傳回值
  • d3.pairs(Array) — 幫我們把陣列配對,適合用在點資料轉為線資料

以上其實只是 d3.js 資料處理的其中一部份,像是 d3.zip 、 d3.permute 或是 d3.transpose 等函式我們並沒有談到,但我們已描述了大部份的使用情境,若有需要的話,讀者可以進一步詳細閱讀 d3.js 的說明文件。

結語

資料整理過程繁瑣又複雜,往往錯一步就步步錯,找半天也看不出來問題出在哪裡。然而資料轉換卻是視覺化必要的一個過程,躲也躲不過,如何利用現有的輔助工具讓我們可以更順利的做出想要的視覺化,這裡可是個很重要的關鍵與門檻喔!所以,不要嫌煩,好好的學習資料轉換的手法吧!


Written by infographics.tw

2 Comments

Ali

感謝你們的分享!
文章提到經過d3.map(scores, function(it) { return it.name; })所得的scoremap似乎不昰單純的object,我試了一下似乎無法用scoremap[“James"] 直接取值,但可用scoremap.get(“James")或scoremap._[“James"],不知是不是我有哪裡弄錯?
再次感謝你們的分享! 獲益良多!

Reply
infographics.tw

Hi Ali, 很抱歉這邊我們寫錯了, 應該要寫成 scoremap.get(“James") 才對. d3.map 產生的是 d3.js 參考 ES6 所特製的一種類似 Object 的資料形態, 若要利用此結果存取資料, 要透過 scoremap.get(" … “) 的方法. 詳細的使用方法可以參考 d3.map 的文件: https://github.com/mbostock/d3/wiki/Arrays#maps

感謝您的提醒, 我們已將錯誤修正 🙂

Reply

發表迴響

你的電子郵件位址並不會被公開。 必要欄位標記為 *