在Gartner最新的對商務智慧軟體的專業分析報告中,Tableau持續領跑。Microsoft因為PowerBI表現出色也處於領導者象限。而昔日的領導者像SAP,SAS,IBM,MicroStrategy等逐漸被拉開了差距。
Tableau因為其靈活,出色的資料表現已經成為BI領域裡無可爭議的領頭羊。而其資料驅動的視覺化和核心思想是來自於Leland Wilkinson的The Grammar Of Graphics ,同樣受到該思想影響的還有R的圖形庫ggplot。
在資料視覺化開源領域裡,大家對百度開發的echarts可謂耳熟能詳,echarts經過多年的發展,其功能確實非常強大,可用出色來形容。但是螞蟻金服開源的基於The Grammar Of Graphics的語法驅動的視覺化庫G2,讓人眼前一亮。那我們就看看如何利用G2和500行左右的純前端程式碼來實現一個的類似Tableau的資料分析功能。
- 演示參見 https://codepen.io/gangtao/full/OZvedx/
- 程式碼參見 https://gist.github.com/gangtao/e053cf9722b64ef8544afa371c2daaee
資料載入
第一步是載入資料:
資料載入主要用到了三個庫:
- axios 基於Promise的HTTP客戶端
- alasql 基於JS的開源SQL資料庫
- jquery datatable JQuery的資料表格外掛
資料通過我存放在GitHub中的csv格式的檔案,以REST請求的方式來載入。下面的程式碼把Axios的Promise變成 async/wait方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Ajax async request const request = { get: url => { return new Promise((resolve, reject) => { axios .get(url) .then(response => { resolve({ data: response.data }); }) .catch(error => { resolve({ data: error }); }); }); } }; |
封裝好後,我們就可以用request.get()方法傳送REST請求,獲取csv檔案。
1 |
let csv = await request.get(url); |
這一步可能會遇到跨域請求的問題,github上的檔案支援跨域。
把資料儲存在一個SQL資料庫中,這樣做的好處是為了下一步做資料準備的時候,可以方便的利用SQL來進行查詢和分析。
1 2 3 4 5 6 7 8 9 10 11 12 |
class SqlTable { constructor(data) { this.data = data; } async query(sql) { // following line of code does not run in full page view due to security concern. // const query_str = sql.replace(/(?<=FROM\s+)\w+/, "CSV(?)"); const query_str = sql.replace("table", "CSV(?)"); return await alasql.promise(query_str, [this.data]); } } |
SqlTable是一個對資料表的封裝,把csv資料存在SQL資料庫表中,提供一個query()方法。這裡要做的是把SQL查詢個從 “SELECT * FROM table” 變成 “SELECT * FROM CSV(?)” 表示查詢引數是CSV資料。因為codepen的安全性限制,執行前向查詢的replace語句(這裡的regex表示把前面是“FROM ”詞的替換為CSV(?)的)在full page view下是不能執行的,所以我用了一個更簡單的假定,使用者的表名就是table,這樣做有很多問題,大家如果在codepen之外的環境,可以用註釋掉的程式碼。
然後把”SELECT * FROM table”的查詢結果(JSON Array)用datatable來展示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function sanitizeData(jsonArray) { let newKey; jsonArray.forEach(function(item) { for (key in item) { newKey = key.replace(/s/g, "").replace(/./g, ""); if (key != newKey) { item[newKey] = item[key]; delete item[key]; } } }); return jsonArray; } function displayData(tableId, data) { // tricky to clone array let display_data = JSON.parse(JSON.stringify(data)); display_data = sanitizeData(display_data); let columns = []; for (let item in display_data[0]) { columns.push({ data: item, title: item }); } $("#" + tableId).DataTable({ data: display_data, columns: columns, destroy: true }); } |
這一步有兩點要注意:
- 資料中,如果列的名字中有包含點,空格等字元,例如Iris資料集中的Sepal.Length,datatable是無法正常顯示的,這裡要呼叫sanitizeData()方法把列名,也就是JsonArray中Json物件的屬性名中的點和空格去掉。
- sanitizeData()方法會改變輸入物件,所以在傳入之前做了一個深度拷貝,這裡利用JSON的stringfy和parse方法可以對JSON相容的物件有效的拷貝。
這裡要注意,Iris資料集中在datatable中的列名都不顯示點,但實際資料並沒有改變。
資料準備
資料載入完畢,我們來到第二步的資料準備階段。資料準備是資料科學專案最花時間的一步,通常需要對資料進行大量的清洗,變形,抽取等工作,使得資料變得可用。
在這一步我們做了兩件事:
一是顯示資料的一個摘要,讓我們初步瞭解資料的概貌,為進一步的資料變形和處理做好準備。
這個是Iris資料集的摘要:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function isString(o) { return typeof o == "string" || (typeof o == "object" && o.constructor === String); } function summaryData(data) { let summary = {}; summary.count = data.length; summary.fields = []; for (let p in data[0]) { let field = {}; field.name = p; if ( isString(data[0][p]) ) { field.type = "string"; } else { field.type = "number"; } summary.fields.push(field); } for (let f of summary.fields) { if ( f.type == "number" ) { f.max = d3.max(data, x => x[f.name]); f.min = d3.min(data, x => x[f.name]); f.mean = d3.mean(data, x => x[f.name]); f.median = d3.median(data, x => x[f.name]); f.deviation = d3.deviation(data, x => x[f.name]); } else { f.values = Array.from(new Set(data.map(x => x[f.name]))); } } return summary; } |
這裡我們利用資料的型別判斷出每一個欄位是數值型還是字元型。對於字元型的欄位,我們利用JS6的Set來獲得所有的Unique資料。對於數值型,我們利用d3的max,min,mean,median,deviation方法計算出對應的最大值,最小值,平均數,中位數和偏差。
另一個就是利用SQL查詢來對資料進行進一步的加工。
上圖的例子中我們利用限制條件得到一個Iris資料的子集。
另外G2還提供了Dataset的功能:
資料處理是一個比較大的話題,我們的目標是利用盡可能少的程式碼完成一個資料分析的工具,所以這一步僅僅是利用alasql提供的SQL查詢來處理資料。
資料展示
資料處理好後就是我們的核心內容,資料展示了。
這一步主要是利用select2提供的選擇控制元件構建圖形語法來驅動資料展示。如上圖所示,對應的G2程式碼圖形語法為:
1 2 3 4 5 6 |
g2chart.facet('rect', { fields: [ 'Admit', 'Dept' ], eachView(view) { view.interval().position('Gender*Freq').color('Gender').label('Freq'); } }); |
圖形語法主要包含以下幾個主要的元素:
幾何標記 Geometry
幾何標記定義了使用什麼樣的幾何圖形來表徵資料。G2現在支援如下這些幾何標記:
geom 型別 | 描述 |
---|---|
point |
點,用於繪製各種點圖。 |
path |
路徑,無序的點連線而成的一條線,常用於路徑圖的繪製。 |
line |
線,點按照 x 軸連線成一條線,構成線圖。 |
area |
填充線圖跟座標系之間構成區域圖,也可以指定上下範圍。 |
interval |
使用矩形或者弧形,用面積來表示大小關係的圖形,一般構成柱狀圖、餅圖等圖表。 |
polygon |
多邊形,可以用於構建色塊圖、地圖等圖表型別。 |
edge |
兩個點之間的連結,用於構建樹圖和關係圖中的邊、流程圖中的連線線。 |
schema |
自定義圖形,用於構建箱型圖(或者稱箱須圖)、蠟燭圖(或者稱 K 線圖、股票圖)等圖表。 |
heatmap |
用於熱力圖的繪製。 |
這裡要注意,intervalstack是官方支援的,但是文件沒有提到,在閱讀G2的API文件的時候,我也發現文件講的不是很清楚,有很多地方沒有講清楚如何使用API。這也是開源軟體值得改進的地方。
圖形屬性 Attributes
圖形屬性對應視覺編碼中的不同元素,大家可以參考我的另一部落格 資料視覺化中的視覺屬性 。
圖形屬性主要有以下幾種。
- position:位置,二維座標系內對映至 x 軸、y 軸;
- color:顏色,包含了色調、飽和度和亮度;
- size:大小,不同的幾何標記對大小的定義有差異;
- shape:形狀,幾何標記的形狀決定了某個具體圖表型別的表現形式,例如點圖,可以使用圓點、三角形、圖片表示;線圖可以有折線、曲線、點線等表現形式;
- opacity:透明度,圖形的透明度,這個屬性從某種意義上來說可以使用顏色代替,需要使用 ‘rgba’ 的形式,所以在 G2 中我們獨立出來。
在構建語法的時候,我們把圖形屬性繫結一個或者多個資料欄位。
座標系 Coordinates
座標系是將兩種位置標度結合在一起組成的 2 維定位系統,描述了資料是如何對映到圖形所在的平面。
G2提供了以下幾種座標系:
coordType | 說明 |
---|---|
rect |
直角座標系,目前僅支援二維,由 x, y 兩個互相垂直的座標軸構成。 |
polar |
極座標系,由角度和半徑 2 個維度構成。 |
theta |
一種特殊的極座標系,半徑長度固定,僅僅將資料對映到角度,常用於餅圖的繪製。 |
helix |
螺旋座標系,基於阿基米德螺旋線。 |
分面 Facet
分面,將一份資料按照某個維度分隔成若干子集,然後建立一個圖表的矩陣,將每一個資料子集繪製到圖形矩陣的窗格中。分面其實提供了兩個功能:
- 按照指定的維度劃分資料集;
- 對圖表進行排版。
G2支援以下的分面型別:
分面型別 | 說明 |
---|---|
rect | 預設型別,指定 2 個維度作為行列,形成圖表的矩陣。 |
list | 指定一個維度,可以指定一行有幾列,超出自動換行。 |
circle | 指定一個維度,沿著圓分佈。 |
tree | 指定多個維度,每個維度作為樹的一級,展開多層圖表。 |
mirror | 指定一個維度,形成映象圖表。 |
matrix | 指定一個維度,形成矩陣分面。 |
注意,在我的程式碼中,為了簡化使用,只支援list和rect,當繫結一個欄位的時候用list,繫結兩個欄位的時候用rect。
除了上面提到的元素,當然還有許多其它的元素我們沒有包含和支援,例如:座標軸,圖例,提示等等。
關於圖形的語法的更多內容,請參考這裡。
生成圖形語法的核心程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
function getFacet(faced, grammarScript) { let facedType = "list"; let facedScript = "" grammarScript = grammarScript.replace(chartScriptName,"view"); if ( faced.length == 2 ) { facedType = "rect"; } let facedFields = faced.join("', '") facedScript = facedScript + `${ chartScriptName }.facet('${ facedType }', {n`; facedScript = facedScript + ` fields: [ '${ facedFields }' ],n`; facedScript = facedScript + ` eachView(view) {n`; facedScript = facedScript + ` ${ grammarScript };n`; facedScript = facedScript + ` }n`; facedScript = facedScript + `});n`; return facedScript } function getGrammar() { let grammar = {}, grammarScript = chartScriptName + "."; grammar.geom = $('#geomSelect').val(); grammar.coord = $('#coordSelect').val(); grammar.faced = $('#facetSelect').val(); geom_attributes.map(function(attr){ grammar[attr] = $('#' + attr + "attr").val(); }); grammarScript = grammarScript + grammar.geom + "()"; geom_attributes.map(function(attr){ if (grammar[attr].length > 0) { grammarScript = grammarScript + "." + attr + "('" + grammar[attr].join("*") + "')"; } }); if (grammar.coord) { grammarScript = grammarScript + ";n " + chartScriptName + "." + "coord('" + grammar.coord + "');"; } else { rammarScript = grammarScript + ";"; } if ( grammar.faced ) { if ( grammar.faced.length == 1 || grammar.faced.length == 2 ) { grammarScript = getFacet(grammar.faced, grammarScript); } } console.log(grammarScript) return grammarScript; } |
這裡有幾點要注意:
- 使用JS的模版字串可以有效的構造程式碼片段
- 使用eval執行構造好的語法驅動的程式碼來響應select的change事件,以獲得良好的互動性。在生產環境,要注意該方法的安全性隱患,因為純前端,eval能帶來的威脅比較小,生產中,可以把這個執行放在安全的沙箱中執行
- 你需要理解圖形語法,並不是任意的組合都能驅動出有效的圖形。
這裡對於select2的多選,有一個小的提示,在預設情況下,多選的順序是固定的順序,並不依賴選擇的順序,然而許多圖形語法和欄位的順序有關,所以我們使用如下的方法來相應select的選擇事件。
1 2 3 4 5 6 7 |
function updateSelect2Order(evt) { let element = evt.params.data.element; let $element = $(element); $element.detach(); $(this).append($element); $(this).trigger("change"); } |
這樣做就是每次選中後,把當前選中的專案移到資料最後的位置。
一些例子
好了,下面我們就來看一些例子,瞭解一下如何使用圖形語法來分析和探索資料。
Iris資料集散點圖
圖形語法:
1 |
g2chart.point().position('Sepal.Length*Petal.Length').color('Species').size('Sepal.Width') |
Car資料集折線圖
圖形語法:
1 |
g2chart.line().position('id*speed'); |
切換到極座標:
圖形語法:
1 2 |
g2chart.line().position('id*speed'); g2chart.coord('polar'); |
Berkeley資料柱狀圖
資料處理:
1 |
SELECT SUM(Freq) as f , Gender FROM table GROUP BY Gender |
圖形語法:
1 |
g2chart.interval().position('Gender*f').color('Gender').label('f'); |
Berkeley資料堆疊柱狀圖
資料處理:
1 |
SELECT SUM(Freq) as f , Gender , Admit FROM table GROUP BY Gender, Admit |
圖形語法:
1 |
g2chart.intervalStack().position('Gender*f').color('Admit') |
Berkeley資料餅圖
資料處理:
1 |
SELECT SUM(Freq) as f , Gender FROM table GROUP BY Gender |
圖形語法:
1 2 |
g2chart.intervalStack().position('f').color('Gender').label('f'); g2chart.coord('theta') |
Berkeley資料分面的應用
圖形語法:
1 2 3 4 5 6 7 |
g2chart.facet('rect', { fields: [ 'Dept', 'Admit' ], eachView(view) { view.coord('theta'); view.intervalStack().position('Freq').color('Gender'); } }); |
更多的分析圖形留給大家去嘗試
總結
本文分享了一個利用純前端技術構建一個類似Tableau的BI應用的例子,整個程式碼統計:
- JS 370 行 JS6
- HTML 69 + 9 + 5 = 83 行
- CSS 21 行
總計474 行,用這麼少的程式碼就能完成一個看上去還不錯的BI工具,還算不錯吧。當然這裡主要是由於開源社群提供了這麼多好的前端庫以供應用,我要做的僅僅是讓它們有效的工作在一起。這個只能算是個原型,從功能和質量上來說都不成熟,但是能在瀏覽器中不借助任何的伺服器來實現BI的資料分析功能,應該會有很多人想要在自己的應用中嵌一個吧?
結合我之前分享的TensorflowJS的文章,下面一步可能是加入預測功能,為資料分析加入智慧,前端應用的前景,不可限量!
參考
- axios 基於Promise的HTTP客戶端
- alasql 基於JS的開源SQL資料庫
- jquery datatable JQuery的資料表格外掛
- select2 JQuery的選擇控制元件外掛
- 相關部落格 使用開源軟體快速搭建資料分析平臺
- 相關部落格 資料視覺化中的視覺屬性