2015年11月17日 星期二

抄程式學 d3.js

以 D3.js 製作的臺灣人口互動統計圖及統計地圖 需要將大量的數據變成會說故事的圖案嗎? 可以用 javascript 函式庫 d3.js 來實現。 想學嗎? 那就抄我的程式吧。 「以 D3.js 製作的臺灣人口互動統計圖及統計地圖」 是我自己學習 d3.js 的練習作業。 假設你己經思考過 「令開發者愛恨交織的 javascript」 裡面所提的問題, 覺得還是可以學 js。 那麼就從零開始, 逐步增加功能, 一小段一小段抄 我的程式每次請只抄看得懂的部分, 並且順手改一下 css 等等外觀版面設計。 配合以下的說明, 應該很快就可以進步到比完全新手還要再高兩級的程度 (大約是中二的概念)。 文中有一些官方文件的連結, 搭配著能動的範例程式來讀比較易懂。 而且先前我卡關的地方, 文中都已列出 (大量搜尋爬文後篩選出來的) 參考文件了, 你可以省下很多摸索的時間。 當然, 這篇文章的步調也會比較快、 省略很多 (你自己必須填入的) 細節。 所以我們先從預備閱讀開始。

一、 預備閱讀

不必擔心, 我也沒有 100% 讀完以下。 但總要讀個五六成、 動手剪貼過一二十個範例, 對 d3.js 有點感覺, 才容易往下讀。

  1. d3.js 作品欣賞
  2. 網頁視覺化利器 - D3.js 簡介 D3.js 初體驗 淺談 D3.js 的資料處理
  3. inkscape 及文字編輯器檢視原始碼, 略學 svg 向量圖檔格式
  4. 英文, 但步調很慢、 很有系統的入門教學: Dashing D3.js; 若有特定主題不懂, 可到 oxxo studio 找相對應主題的中文教學文。

當然, 本文也假設你略懂略懂 css (例如 id、 class 等等) 以及 javascript 的 正規表示式 等等基本功。

OK, 現在請準備三個空白的檔案: main.html、 main.js、 main.css, 隨時使用 jshint 跟 jscs 來整理你的 .js 程式, (大致) 循序根據每一節的描述自己挖出我程式中 你看得懂的片段 來剪貼修改吧。

二、 讀入資料檔

我的全域變數都放在 G 裡面。

js 最煩的地方就是連讀個檔案都要非同步。 像我們需要讀三個檔案, 按照標準寫法就需要三層的 callbacks, 都快哭出來了。 還好 mbostock 大大寫了一個 queue 函式庫, 讓程式碼變簡單很多。

讀入人口統計資料 G.fullCensusData 之後, 每個鄉鎮區都給他補上一個欄位, 直接連到鄉鎮邊界座標 G.townBoundary 裡面該鄉鎮區的對應資料, 這樣等一下要畫地圖比較方便。

fullCensusData[i]['男'] 這個陣列裡面存的是某鄉鎮區 0 歲到某歲的所有男性人數。 最前面再補上一個 0, 這樣以後就可以用 fullCensusData[i]['男'][30] - fullCensusData[i]['男'][20] 來計算某鄉鎮區 20、 21、 22、 ... 29 歲人口總數。

三、 拖動條 slider

我們需要拖動條/控制條/slider 來讓用戶指定年齡範圍。 找到 D3.js Slider 模組 可用。 其實它有提供 「範圍型」 的 slider, 也就是一條 slider 有兩個控制端點。 但對我們的應用而言, 比較好的使用者體驗應該是: 「固定年齡範圍; 改變起始年齡時終止年齡也同步等量平移」。

四、 縣市選單

建議一開始先略過縣市選單, 直接寫死 prepareTargetRegion('臺中市') (或其他縣市) 讓程式能動能測試就好。 這部分跟 d3 比較沒關係; 等其他部分做完再回頭來補上。

我的程式主要參考 這一頁問答 官方文件關於 on 的用法。 順便幫不熟悉 DOM 的同學 (我) 筆記一下: 在 event listener 裡面, 可以透過 this 取得 「觸發事件」 的物件。 取得 this 之後, 有哪些欄位可用? 以 select 物件 (就是這裡的縣市選單) 為例, 搜尋 「javascript dom select properties」 可以找到 這個清單 (還要加上所有不同物件的 共通欄位)。

五、 切換分頁

我是為了 cordova 才認真學 javascript 的, 所以當然需要學會製造 「手機 app 切換分頁」 的效果。 找到 jcps, 底層是用 jquery 做出來的, 但它似乎跟 d3 不合。 於是改拿 這個小範例 把底層改用 d3 重做出一個 div-switcher-d3.js 函式庫。 我寫的那個函試庫只求能動就好, 讀者可略過, 不必研究實作細節 (遮臉)。 僅需要在三個原始碼檔案 (main.html、 main.css、 main.js) 裡面搜尋 "switch" 相關的片段就知道如何使用。

六、 不同層次/不同頻率的更新

這一節暫時不抄程式碼, 而是談一個心得。 不論是 mbostock 大大自己寫的或是網路上找到的許多 d3.js 範例程式都很精簡, 一句話串到底 (chaining) 就做完了。 但是當你的程式要跟讓使用者互動時, 就要考慮一個問題: 哪些程式碼只要最開始做一次就好? 哪些程式碼需要放在 (哪個?) event listener 裡面經常重做? 也就是說, 真槍實彈上場時, 許多原本單純的串燒範例程式都必須拆開來某幾塊放在這個副程式、 另幾塊放在那個副程式。 我這個程式分成三等級不同的更新頻率:

  • [第一層] 從頭到尾只做一次的事 (例如讀檔案、 建立 slider) 放在 init() 裡面。
  • [第二層] 每次更換縣市時才需要重做一次的事 (例如 「把目前縣市資料挑出來」、 重畫三張圖) 放在 prepareTargetRegion() 裡面。
  • [第三層] 使用者頻頻拖動 slider 改變年齡範圍就馬上需要重做一次的事 (例如更新長條圖的長度、 男女人口圖的鄉鎮區落點、 人口比例圖的顏色) 放在 refreshBarChart()、 refreshGenderPlot()、 refreshPopMap()。

就這個程式而言, 拉動 slider 時, 只有數字會變化, 至於鄉鎮區並不會新增或移除。 所以 .enter().append() 跟 .exit().remove() 可以放在第二層; 用 .data() 建立資料連結也是。 只有 .transition() 改變屬性 (長條長度/鄉鎮名字座標/鄉鎮地圖顏色) 要放在第三層。

不過要把原本的串燒程式碼拆開有時還蠻費工的, 也很難做得很完美。 通常就是先把整串放在最頻繁更新的 listener 裡面, 先求能動。 再試著把 「起始/結束」 的程式碼往外搬。 有時還需要借用全域變數來傳遞資訊 -- 詳見 「圖例」 那一節。

七、 長條圖 & 人口百分比函數

長條圖最簡單。 D3.js 簡介 跟 dashing d3 的 「Adding a DOM element」 這兩篇完全適用。

想要得知一個元件 (例如 #bar-chart-proper) 的寬度, 可以用 d3.select('#元件的id').style('width') 詳見 這個問答。 順便一提: 根據 這個問答, 當你要把一個字串拿來跟其他數字做加減乘除等等運算時, 可以用 + 號取代 parseInt 或 parseFloat。 (但它若單獨出現則不行。)

特別注意: 畫面上代表一個鄉鎮區的每一列 (.bc-entry) 裡面包含兩個子元素: 左邊的鄉鎮區名 (.entry-text) 及右邊的著色長條 (.entry-bar)。 所以建立時不能用 chaining 的方式一句串到底, 必須拆成三句。 詳見 這個問答

本程式中最常用到的人口百分比函數 popRatio 所計算的是 「某鄉鎮區裡面, 某年齡層 (例如 20 到 24 歲) 佔該鄉鎮區總人口的百分比 (不分男女)」。

八、 男女人口比較圖

想要比較每一區男女人口的差異, 如果採用男性人口及女性人口分別當作 x 軸與 y 軸, 效果可能不會很好。 因為 「兩者差異」 相對於 「該鄉鎮區該年齡層總人口」 可能很小, 所有的點可能幾乎都落在 45 度線上。

所以橫軸採用 (男女人口總計) popRatio(); 而縱軸則要找一個突顯男女人口差異的數值。 可以把 「隨機取樣的一群人當中的男性人口數」 當成一個二項式分佈的隨機變數, 然後 用常態分佈來近似估計 「該樣本男性人口數除以該樣本總人口數」 的 Z 值。 maleBinomialZ() 就是在 (對某鄉鎮區) 算這個值。 不論取樣人口多寡, 這個值落在正負 3 之間都算正常; 正負 6 (六個標準差) 之外就很奇怪。 [2016/9/28 更正: 先前的運算式有誤。 正確算式很簡單: (m-f)/sqrt(m+f) 但我在分母補加 0.01 避免除以零的錯誤。]

初次實作可以先把 svg 方塊的寬度與高度寫死 -- 例如參考 這個回答 順便畫框。 第二輪改進時再考慮使用者放大縮小視窗的狀況: 如果這兩張 猴子 斑馬 圖 (遇到視窗縮放事件時的變化效果) 符合你想要的需求, 那麼可採用 這個問答的建議。 把寬高寫死在 viewBox 裡面, 你的程式面對的永遠是這個固定大小的 svg 方框; 縮放事件自有 css 處理, 你完全不必操心。 這叫做 「responsive svg」。 請在三個檔案裡面搜尋 「rsvg」 相關片段。 不過 slider 沒有做相同的處理, 所以每當訪客改變視窗大小之後就會亂掉, 此時必須重新整理頁面。

createAxes() 旨在建立座標軸。 也請參考 oxxo (中文) dashing d3.js 這兩篇。 因為 x 軸的範圍會隨著實際資料而改變, 所以 「真的把座標軸畫出來」 的程式碼放在 (第三層的) refreshGenderPlot() 裡面。 大致上是從 這個問答 抄過來的。

d3.js 的縮放機制 另一個 (與視窗縮放獨立互不干擾的) 縮放效果是 zooming, 也就是使用者在 svg 裡面用滑鼠滾輪拉近拉遠。 mbostock 給了四種寫法 (見連結中的連結), 其中 SVG geometric zooming 似乎最受歡迎 -- 網路上搜尋到的例子幾乎都是這種。 大推這一篇: understanding zoom behavior bindings。 該文作者發現兩種不同寫法, 當你放大後要移動時, 該文的左圖很順暢, 右圖卻卡卡。 作者也不解原因。 總之想要得到較佳的效果, 應該先 call(zoom) 然後才 append('g'), 最後才從 (svg 的) <g> 元素 底下生出整張圖的其他元素, 也就是整張圖都被包在一個 <g> 元素裡面一起縮放 (都必須是 <g> 的 children) 至於 d3.behavior.zoom() 裡面的 listener 則必須作用在這個 <g> 元素身上。 右圖摘要 zoom 的用法。

九、 地圖

參考 「地理區塊視覺化」 或是 mbostock 的教學文, 畫出全國縣市邊界圖。 核心重點是這兩句:

  1. 先用 mapObjs = d3.geo.path().projection(mproj); 建立一個即將用來容納邊界座標的 「袋子」 mapObjs。 為什麼普通的陣列或 hash table 不夠用? 因為裡面還必須包含投影方式等等座標轉換資訊。
  2. 然後透過這句: counties.enter().append('path').attr('d', function(d) { return mapObjs(d); }) 把每個縣市的邊界座標畫出來。 請注意: 我真實的程式碼更精簡一點, 因為 mapObjs 外面那一層空殼匿名函數可以直接省略。

其他都跟一般 svg 繪圖一樣。 例如想要製造 tooltip 的效果, 也就是當 mouseover 一個縣市時, 要顯示縣市名稱, 根據 這個問答, 可以 (在 append('path') 之後) .append("svg:title").text(function(d) { return d.properties['C_Name']; });

但該如何讓標的縣市落在地圖正中央, 而不需要像 「地理區塊視覺化」 一文一樣 d3.geo.mercator().center([121,24]).scale(6000) 把臺灣座標和縮放尺度寫死在程式裡面? 可以採用 mbostock 對這個問題的回覆, 用 mapObjs.bounds() 從標的縣市 (G.targetCity) 的邊界座標計算出適當的縮放及平移初始值, 得到正確的投影變換 mproj。

標的縣市的鄉鎮區邊界也用相同的方式畫出。 兩者放在同一張地圖上、 有著相同的投影變換, 所以不需要另開一個 path; 但建立時最好給不同的 (css) class, 這樣後面才能夠很方便地 (用 .selectAll('path.county')) 撈出縣市邊界, 或 (用 .selectAll('path.town')) 撈出鄉鎮區邊界。

十、 色彩

再來要上色, 以顏色來區分各鄉鎮區某特定年齡層的人口多寡。 原本的想法是採用電腦上常見的 HSV 著色方式, 以暖色系代表較高的百分比、 冷色系代表較低的百分比。 但是 mbostock 大大開釋 Lab 或 HCL 比較符合人類視覺心理。

好的, 那麼就用 Lab 吧。 百分比最低的鄉鎮區還是塗藍色、 百分比最高的鄉鎮區則塗紅色。 位於上下限平均值的就填白色好了。 「把某範圍的數值 (domain) 對應到另個範圍的數值 (range) 去」 這可以用 scale。 這篇中文 有最簡單的解釋; 另外詳見 oxxo studio dashing d3 的文章。 這篇 很精簡地摘要 scale 的重點。

又因為我們的 「百分比-顏色」 對照表不只兩端點, 而是有三個點, 也就是切成兩段, 所以採用的是 官網 wiki 所說的 「piecewise scale」。

十一、 圖例

圖是上色了, 但什麼顏色代表什麼意義, 恐怕只有程式設計師知道 (而且我還採用了一個怪怪的著色規則)。 於是需要在旁邊貼上圖例 (legend) 讓閱圖者參考。

就拿 Susie Lu 所寫的 d3 SVG Legend 來用吧。 同樣遇到 「拆串燒」 的問題: 如果把 「拆圖例、建圖例」 整個全部寫進第三層的 refreshPopMap() 裡面, 在拉動 slider 改變年齡起迄範圍的時候, 圖例會一直閃, 整體速度會變慢。 我提出 新增功能請求, 作者改版之後, 動態更改圖例就變得很簡單了。 假設圖例數目維持不變, 只有數字及顏色的範圍改變, 那麼可以在外層建立「顏色型圖例物件」 var legendPainter = d3.legend.color()... 及圖例的 svg 元件, 然後在內層裡面 (1) 改變 legendPainter 的 scale (2) 用 legendPainter 更新圖例的 svg 元件的文字標籤。 詳見 一個短短的範例。 在完整的 main.js 裡面, 建立 「顏色型圖例物件」 時我還用了 .ascending(true) 來顛倒上下。 更新文字標籤時, 因為著色那句 towns.transition().attr('fill', ...) 用過 scale 之後已無其他用處, 就直接把它點來, 將數字乘以 100 倍, 又回收丟給圖例再用一次。

特別注意: 當使用者用滑鼠滾輪縮放地圖的時候, 我們不希望圖例也被縮放。 #pop-map-panel 呼叫了 pmzoom, 而 pmzoom 的縮放對象是 #pm-canvas。 所以圖例必須放在 #pm-canvas 之外。 於是我建了一個 #pm-zoom-or-zoomless 來同時容納 #pm-canvas 跟圖例。

十二、 結語

call graph 右圖是用 code2flow 所產生的 call graph (函數呼叫關係圖)。 以下是其他心得, 雜亂地記在這裡。

call() 是幹嘛用的? 不過就是把目前這個物件當成參數傳給某函數而已。 也就是 「把參數跟函數擺放的位置對調」 的概念。 目的是為了讓 chaining 語法一路順下去不被打斷。

d3.select(dom) 可以把 DOM 物件轉成 d3 的 selection; 用 sel.node() 可以把 d3 的 selection 轉成 DOM 物件。 學會 d3 物件跟 DOM 物件互轉, 就可以針對螢幕上的一個物件自由呼叫 d3 函數或是自由取用它的 DOM 函數/屬性。 例如先前提到 event listener 裡面的 this 是一個 DOM 物件, 所以可以用 d3.select(this) 迅速將它轉成 (比較高階的) d3 物件。

「不同區域著上不同顏色的地圖」 英文稱為 choropleth。 它只能 (用顏色)呈現一個維度的資訊, 比較適合 「相鄰地域有高度相關性」 的場合。 如果想要在一張圖裡面同時呈現很多資訊, 還是類似男女人口比較圖那樣的座標方式比較合適。 除了顏色之外, 還有大小及 X Y 座標三個維度可以更多元、 更清楚地呈現數值。

網路上很多範例程式 (包含 mbostock 大大的) 在幫變數取名字時, 直接拿函數的名字來用。 這對初學者而言, 閱讀起來很吃力。 所以我故意採用 mproj (而不是 projection)、 mapObjs (而不是 path)、 ratio2color (而不是 scale) 之類的名字。

如果你照著本文抄程式, 還有哪裡看不懂, 或是哪裡卡關很久, 歡迎留言提問, 我再來補充說明或補上參考連結。

7 則留言:

  1. 貴哥,這是了不起的初衷。您讓平民一樣有發展的機會。

    回覆刪除
  2. 貴哥, 又是一次條條大路通羅馬!

    我最近看網頁設計的影片, 裡面介紹到資料視覺化, 我 google d3.js, 你的玩具烏托邦 google 排序第二名我就點進來了.
    沒想到是貴哥的站!
    我就想說貴哥以前的站 (米色那個) 文章都有一點時間了, 很可惜台灣沒有這種 Free/Libre software 強烈的自由信念.
    沒想到很高興又可以看到貴哥的新站!

    我希望我能繼續堅持犧牲方便性換取自由.

    回覆刪除
    回覆
    1. 另外, 如果舊站新站互相讓內文做連結, google 好像會認為新網站重要性很高, 可能可以排比較前面.

      刪除
    2. 謝謝支持 :-) 那個「資訊人權貴隨便記」(米色的)因為無法承受流量,所以我就停用了。 後來把戰鬥文都寫在「資訊人權貴ㄓ疑」。 這兩年覺得戰累了,轉而比較常寫教學文「玩具烏托邦」。 我的部落格簡史可以參考這裡哦: http://user.frdm.info/ckhung/g/bsp.php 一起加油吧!

      刪除
  3. 貴哥,烏托邦理想很好,但如果懂得運用方法讓每一個人快速學習,大幅降低學習門檻,再好完美不過。
    建議閣下建立影音檔放在 youtube ,以饗普羅大眾。

    回覆刪除
  4. 這個我很同意耶匿名,
    我自己就是相當依賴 youtube 找東西的. 對於像我這樣很笨的人, 我沒有能力理解文字, 所以很多學習前的功課都要去 youtube 裡面先嘗試找找看, 如果我找不到某關鍵字的影片, 我會認為它不重要 (因為不存在).

    (文章有點長, 沒時間的話直接看最後面結論.)

    後來我開始理解到很多知識是搜尋引擎找不到的, 即使一領域很很重要的關鍵字, 搜尋引擎都不一定找的到, 所以 "找不到" 不等於 "不重要". 但是 "只有比較聰明的人才有這樣的視野", 像我比較笨, 以前就沒這樣的視野, 我只依靠搜尋引擎的判斷, 認為 搜尋不到等於不重要或不存在.

    潛社群 / sub-community 是我自己推測和假設的一種現象, 就是很多知識和問題的解答是用搜尋引擎像是 google 所不能找到的. 非常大量的這些問題藏在潛社群裡面, 要麼用關鍵字很難找到, 要麼用關鍵字也找不到 (因為那個網頁沒有相關關鍵字留在那裡), 要麼 google 根本沒辦法存取 (不是被鎖住就是 google 沒有索引內容).

    比如 ffmpeg 是 Linux 下相當重要的影像處理 utility, 但是今天完全沒聽過 ffmpeg 的人, 今天想要在 Linux 做影片轉檔, 他翻遍 google 找了一堆教學文就是不會得到結論說 ffmpeg 這個東西是很重要的關鍵字, 因為從 google 的搜尋結果看不出來. 反而只會找到一些不太可靠的各種 GUI 陽春影片處理 utility, 還以為 Linux 下沒辦法處理影像.

    簡單來說, 放到 youtube 上面有幾個好處 :

    1. 對笨的人 (像以前的我) 很容易理解, 因為影片的特性就是很容易理解內容.
    2. 對笨的人 (像以前的我) 很容易了解這個東西的實際應用, 因為影片的特性很容易看到最後實際的應用. 我覺的這還差蠻多的, 一個再好的東西我看半天看不到他的用途 (基於不了解) 我通常就放棄了, 但資訊這種東西往往要有很多的前背景知識才有辦法等待前期的學習到最後看到它的應用價值, 而 youtube 很快就可以理解 : "喔原來最後是這個用途!".
    3. 對笨的人 (像以前的我) 是一個很重要的資訊判斷管道, 我會錯誤假設大的專案通常都會有人使用, 然後不論如何多多少少會被分享 youtube 上. 雖然這是膚淺的判斷但在我還沒判斷能力的時候我就是這樣想的.

    其實也不一定要 youtube, vimeo 也可, 然後影片其實我覺地 "有沒有深入無所謂", 最重要最重要的是呈現 "實際應用", 以及讓笨的人知道有這麼一個東西確實存在, 這樣就夠了.
    比如我今天看半天看不懂 Apache 是什麼東西, 今天只要看到一隻影片, 給我看到 apache 可以把本機的資料傳送到自己的瀏覽器 (local 也無所謂) 然後看到 html 網頁代碼最後轉換成實際 browser 裡面的網頁內容, 我就會瞬間恍然大悟 : "喔原來網頁伺服器是這個意思!", 即使影片是隨便拍的只有 3 分鐘.

    回覆刪除
  5. 呃~~ 好吧, 我考慮看看。 因為寫字對我來說比較低成本高效益。 錄影片很不習慣,有金錢誘因才會想要做 :-) 以後若有適當主題我再來試試拍極短篇。 程式設計我覺得短片很難有幫助。

    回覆刪除