treemap - cover

還記得最近爆紅的台北市政府預算視覺化嗎?它使用了 D3.js 的 Treemap 與 Force Layout 製作;前幾個月我們已經分享過 Force Layout 的實戰教學 ( 「D3.js 入門系列 - Force Layout 教學」 ) ,今天就趁這機會讓我們一起來看看如何使用 D3.js 的 Treemap Layout 製作預算視覺化吧!

Treemap 結構是一種將樹狀結構資料 ( 例如公司組織架構、政府組織架構 ) 以矩形分割的方式呈現在平面上的一種視覺化結構。他的歷史可以回推至 1990 年初期,由馬里蘭大學的 Ben Shneiderman 教授 ( 視覺化界的權威 ) 提出。下圖為一個典型的 Treemap Layout:

treemap example

利用切割矩形的方式,Treemap 可以將較高層級的資料不斷的往低層級切分,使得  Treemap 本身可以表現相當多層級的資料;而在互動功能的加持下,當切割出來的矩形過小時,我們可以透過放大、縮小的瀏覽模式來看到更細節的資料,因此 Treemap 可以說相當適合用來表現多層式的樹狀結構資料。

這次的另一個主題 — 政府預算資料,恰好有著這樣的結構;每筆預算依「款、項、目、名」區分,比方說 2016 年度「台北市教育局主管 ( 款 ) 、教育局 ( 項 ) 、教育資源管理 ( 目 ) 、各項教育業務 ( 名 ) 」這筆預算的金額為 480 億元。那就讓我們試著來把兩者結合在一起吧!

初探 Treemap

若你之前有讀過 D3.js 實戰系列的「D3.js 入門系列 - 泡泡圖製作教學」、「D3.js 入門系列 - Force Layout 教學」等文章,那你對我們接下來要做的事可能已經頗熟悉了;D3.js Treemap Layout 的使用方式與 Force Layout 或 Pack Lyout 類似,由我們提供一組符合規格的資料給 D3.js ,D3.js 便會幫我們產生資料應該對應到的座標位置。

舉例來說,若我們有一組數字想要用 Treemap 表現出來:

  4,2,6,6,8,9,9,2

我們要先將資料轉為一個個的 Javascript 物件陣列,以「 value」屬性來儲存其值:

  [
   {value: 4}, {value: 2}, {value: 6}, {value: 6}, 
   {value: 6}, {value: 8}, {value: 9}, {value: 9}, {value: 2}
  ]

接著,把這個陣列放到一個頂層物件之中:

  root = { children: [{value: 4}, {value: 2}, ... ]}

這就是 Treemap Layout 所需要的資料格式,每個物件有一個「value」屬性來代表其值,也可能會有一個「children」參數來表示隸屬於它的子物件。資料準備好以後,我們利用 D3.js建立欲使用的 Treemap 物件,並設定繪製範圍的長寬 ( 900 x 400 ):

  var treemap = d3.layout.treemap().size([900,400]);

接著,利用 treemap.nodes 幫我們計算資料的座標與長寬:

  var nodes = treemap.nodes( root );

這時候, D3.js 會在每個物件中填入以下幾個參數:

  • x — 矩形左上角的 X 座標
  • y — 矩形左上角的 Y 座標
  • dx — 矩形的寬
  • dy — 矩形的長
  • depth — 資料深度 ( 頂層為零,下一層為 1 ,依此類推 )

同時會將所有物件展開到一個陣列之中回傳 ( 上例的 nodes 變數 )。這時候我們便可以利用以下的 D3.js 程式碼繪製矩形區塊:

  d3.select("svg").selectAll("rect").data(nodes).enter().append("rect")
    .attr({
      x: function(it) { return it.x; },
      y: function(it) { return it.y; },
      width: function(it) { return it.dx; },
      height: function(it) { return it.dy; },
      fill: "none", stroke: "black"
    });

執行結果如下:

treemap first try

簡單吧!

台北市 2016 年度預算資料

資料視覺化的一個主要難題是,怎麼把資料變成我們想呈現的形式。從先前的北市開放資料我們已能取得北市年度預算的 CSV 檔,但要進一步運用,我們需要能把 CSV 轉換成 Treemap 需要的 JSON 架構。 恰好, D3.js 提供了一些輔助工具讓我們可以快速做到這點。首先我們從預算視覺化專案網站取得預算資料、並整理成如下的格式,存於 budget.csv 檔 ( 點我下載 ):

  款,項,目,名,總額
  市議會主管,市議會,一般行政,行政管理,198789794
  市議會主管,市議會,議事業務,大會議事工作,104793610
  市議會主管,市議會,議事業務,經常議事工作,454816327
  市政府主管,秘書處,一般行政,行政管理,177080164
  市政府主管,秘書處,市政綜理業務,市政綜理,62588827
  市政府主管,秘書處,聯合服務,為民服務,7694318

接著利用 d3.csv 函式載入該檔:

  d3.csv("budget.csv", function(data) { ... });

( 編按: 若你在本機使用 d3.csv 可能會碰到錯誤,這時可改用 這個網址 取代”budget.csv” )

回呼函式中的 data 即為讀入的 csv 資料陣列,裡面每一個項目都對應到了一筆資料。接著我們在回呼函式中利用 d3.nest 來做資料的轉換:

  d3.csv("budget.csv", function(data)  {
    var nested = d3.nest()
      .key(function(d) { return d["款"]; })  // 分別使用「款」「項」「目」來製作階層
      .key(function(d) { return d["項"]; })
      .key(function(d) { return d["目"]; })
      .entries(data); // 使用的資料來自 d3.csv 的回傳值
  });

nested 會以類似上述 Treemap Layout 資料格式的方式呈現,不過有兩個地方不太相同:

  1. nested 本身是個陣列
  2. 用來記載子層級物件的參數不叫 children ,而叫 values。
  3. 儲存每筆預算數字的參數不叫 value,而叫「總額」,

第一個問題我們可以簡單的透過手動包裝來修正:

  nested = { values: nested };

至於第二個問題,由於 treemap 提供設定子層級參數的選項,我們可以透過下列程式碼讓他不認 children 而改認 values:

  treemap.children(function(d) { return d.values; });

同樣的,第三個問題可以透過呼叫 treemap.value 來設定取用數值的方式:

  treemap.value(function(d) { return d["總額"]; });

至此,我們準備好要來做預算資料視覺化囉!

預算資料視覺化

快速結合上述兩段,我們可以得到下列程式碼:

  d3.csv("budget.csv", function(data)  {
    var nested = { values: d3.nest()         // 包覆 d3.nest 產生的結果
      .key(function(d) { return d["款"]; })  // 分別使用「款」「項」「目」來製作階層
      .key(function(d) { return d["項"]; })
      .key(function(d) { return d["目"]; })
      .entries(data) // 使用的資料來自 d3.csv 的回傳值
    };
    var treemap = d3.layout.treemap().size([900,400]);
    treemap.children(function(d) { return d.values; }); // 改用 values,而非 children
    treemap.value(function(d) { return d["總額"]; }); // 改用總額,而非 value
    var nodes = treemap.nodes( nested );
    d3.select("svg").selectAll("rect").data(nodes).enter().append("rect")
      .attr({
        x: function(it) { return it.x; },
        y: function(it) { return it.y; },
        width: function(it) { return it.width; },
        height: function(it) { return it.height; },
        fill: "none", stroke: "black"
      });
  });

執行的結果如下:

treemap with budget - simple version

加入互動與樣式

做到這個部份,基本的 Treemap Layout 已經可以說是能夠上手應用了,只是我們還需要額外的元素來讓這個視覺化更完整,包括:

  • 不同分類的區塊用相近的顏色填滿
  • 點擊區塊時可以局部放大
  • 加入文字表現當前區塊代表的預算

要實現這些要素的概念都不困難,但程式碼頗繁瑣,我們在這邊簡單的描述要如何達成這些效果就好,不再深入分析完整的程式碼。

♠ 區塊依分類填滿顏色

填滿顏色可以利用 d3.attr 搭配 SVG 的 fill 屬性來做到,只是我們若想要快速地對不同分類快速設定不同的顏色的話,可以搭配 category20 來使用:

  var color = d3.scale.category20();
  d3.select("svg").selectAll("rect").attr({fill: function(d) {
    return color(it.key || it["款"]);
  });

♠ 點擊區塊時局部放大

為了達成這個效果,我們利用 d3.scale.linear 將座標依據我們要放大的範圍重新定義:

  var xmap = d3.scale.linear().domain([newX, newX + newWidth]).range([0,900]);
  var ymap = d3.scale.linear().domain([newY, newY + newHeight]).range([0,400]);

接下來在設定矩形座標時就可以運用這個轉換函式,例如轉換 X 座標與寬度:

  d3.select("svg").selectAll("rect").attr({
    x: function(d) { return xmap(d.x); },
    width: function(d) { return xmap(d.x + d.dx) - xmap(d.x); }
  });

♠ 加入文字

我們可以利用加入矩形的方式加入文字,只是記得要設定文字內容以及正確的座標位置。這時可以簡單的利用 SVG 的垂直 / 水平置中設定快速的將文字置中:

d3.select("svg").selectAll("text").data(nodes).enter().append("text")
  .attr({
    x: function(d) { return xmap(d.x + d.dx/2); },
    y: function(d) { return ymap(d.y + d.dy/2); },
    "text-anchor": "middle", // 水平置中
    "dominant-baseline": "central" // 垂直置中
  })
  .text(function(d) { return d.key || d["名"]; });

♠ 更多的要素

以上三者只是基本款的變化,我們可能還會考慮下面的問題:

  1. 矩形太小的時候,文字還要顯示嗎?
  2. 是否只顯示某一個層級的文字就好?
  3. 縮放的過程是否需要動畫?
  4. 填滿顏色是否應該在不同縮放等級時稍微有變化,以做出區隔?
  5. 邊線該怎麼呈現?

完整的視覺化可以考慮的問題相當的多,這邊就交給大家自己去思考應該要如何達到這些效果吧,我們在這裡提供一個半成品的範例與其程式碼供大家參考,大家可以研究看看是否我們還可以做出更多的變化:


你可以在上面這個例子中四處點擊看看,程式會自動顯示適當等級的文字,當點擊到最底層的預算時,便自動跳回頂層重新瀏覽。程式碼可以在這個 Github 頁面ex4.js 找到,如果你想要下載後自行修改看看效果如何,只要開啟 ex4.html 即可。

結語

這次的程式碼複雜度相較以往的實戰都更複雜了一些,除了最基本的 Treemap Layout 使用以及運用 D3.nest 來做資料轉換,也涉及使用者互動、各種場合的文字顯示與座標轉換,不曉得你是否有跟上呢?當我們可以自由運用這些視覺化手法時,我們的創意才能真正自由自在地發揮,所以不要在望著程式碼發呆了,現在就開始實戰練習吧!


Written by infographics.tw

發表迴響

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