原發於知乎專欄:zhuanlan.zhihu.com/ne-fe
最近在視覺化社群中,一個簡潔美觀,沒有額外依賴的圖表庫 Frappé Charts 額外得火,Github 的 Star 數直逼 10000。作者沒有選用現有圖表方案而選擇自行開發的原因來源於自身業務定位與視覺需求,一方面適配研發產品的整體風格,另一方面是由於業務只需要簡單的圖表。
我們驚訝於 Frappé Charts 的迅速走紅,也對其簡潔、無依賴的設計充滿了好奇,因此@淡蒼、@無止休 與 @趙陽 一起對其原始碼進行了閱讀,同時嘗試自己動手實踐了些最基礎的圖表 Taco。大家往往會直接使用現成的庫去完成業務中的圖表研發,我們也是如此,因此在原始碼閱讀與動手實踐的過程中,我們精心挑選了若干細節,希望大家通過此文能對基礎圖表庫有更加深入的瞭解。
整體架構
縱觀 Frappé Charts 的整體架構是比較清晰的。基於抽象的 BaseChart 之上,再對含有座標軸的 Chart 進行了一次抽象,座標軸體系下的圖表主要有 LineChart、ScatterChart 與 BarChart。而非座標軸的 Chart 則有 PieChart、Heatmap 與 PercentageChart。因為完全沒有第三方依賴,因此對於 Frappé Charts 而言,一系列動畫函式、計算函式以及渲染函式都需要內建實現。
BaseChart 抽象
讓我們先拋開 Frappé Charts,一起來思考一下資料最終是如何轉化為圖表的。其實可以歸結為兩個階段,計算與渲染,計算包括傳入資料的預處理、傳入資料的加工、座標軸位置與刻度確定、圖形位置確定等等,而渲染就將傳入資料經過計算得到的圖形資料渲染成為圖形,可能是 SVG、Canvas,也可能是其他形式。
Frappé Charts 的 BaseChart 作為核心部分也基於同樣的思想。setup 中的 refresh 函式將整個繪製的過程通過一系列的類成員函式進行抽象,其他 Chart 在繼承 BaseChart 後,會去覆蓋相應的類成員函式來達到實現自身圖表的目的。
refresh(init = false) {
...
this.set_width();
this.setup_container();
this.setup_components();
this.setup_values();
this.make_graph_components(init);
this.make_tooltip();
...
}
複製程式碼
注:上圖表示了 refresh 中各函式的實際呼叫,會出現對繼承子類(AxisChart、BarChart)的類成員函式直接呼叫(想想為何可以),而在父類中卻沒有對應實現(例如:BaseChart 中沒有 setup_values 類成員函式),可能會對理解造成一些困擾。
- set_width 確定圖表的寬度。
- setup_container 來渲染容器與繪製區域,這是所有圖表統一的。
- setup_components、setup_values 對於具有座標軸的圖表而言是計算與繪製座標軸位置、刻度值,對於其他圖表例如 PieChart 與 PercentageChart 則是資料的彙總。
- make_graph_components 是最為核心的圖表內容繪製,各個圖表均有不同的實現,裡面也會附帶動畫效果,動畫部分下文中會再詳細介紹。
- make_tooltip 例項化一個 Tooltip 繫結在圖表中,這個行為在各圖表也均具有。
在我們自己的實現中,我們更強調資料驅動檢視的思想,將計算與渲染的完全分離解耦,即計算都完成後再統一進行渲染,而不是邊計算邊渲染,這樣顯得更加清晰。
當然圖表還需要在 PC 端與移動端下均保持自適應,因此在 Frappé Charts 中,我們對 resize 事件與 orientationchange 事件進行了監聽,當其發生改變時,會對重新進行計算與渲染。
bind_window_events() {
window.addEventListener('resize', () => this.refresh());
window.addEventListener('orientationchange', () => this.refresh());
}
複製程式碼
平面座標軸
在平面座標軸的實現上,X 軸相對簡單,只需要將圖表寬度減去兩側 padding 與 Y 軸寬度後根據傳入 labels 數量做等額劃分即可。考慮部分 X 軸 label 會過長髮生溢位情況,我們需要對文案進行長度判斷,對於過長部分用省略號來代替即可。
Y 軸座標刻度的計算則相對複雜,舉個例子,當前 Y 軸數值為 [-25, 89, 1, 90, 107]
,獲取 Y 軸的 maxValue(107) 與 minValue(-25) 後,如果只是單純相減後除以一個刻度線數量的固定值( (107 - (-25)) / 固定值 5
,得到刻度值[-25, 1.4, 27.8, 54.2, 80.6, 107]
),刻度會顯得雜亂。那麼 Frappé Charts 在刻度劃分上先進行了分類討論:
- maxValue 與 minValue 均大於 0,根據配置判斷時在 0 ~ maxValue 還是 minValue ~ maxValue 進行刻度劃分。
- maxValue 與 minValue 均小於 0,求絕對值後與大於 0 同理,只是對於最終劃分的刻度會進行 reverse 並乘以 -1。
- maxValue 大於 0 而 minValue 小於 0,將對 0 ~ MaxValue 進行刻度劃分,計算完成後做 -minValue ~ 0 部分補齊。
對應我們之前的例子屬於第三種情況,先求解 0 ~ 107 的刻度劃分,maxValue(107) 與 minValue(0) 均為非負整數,我們給出如下刻度計算函式:
function getIntervals(maxValue, minValue) {
let [normalMaxValue, exponent] = normalize(maxValue);
let normalMinValue = minValue ? minValue / Math.pow(10, exponent) : 0;
normalMaxValue = normalMaxValue.toFixed(6);
let intervals = getRangeIntervals(normalMaxValue, normalMinValue);
intervals = intervals.map(value => value * Math.pow(10, exponent));
return intervals;
}
複製程式碼
先將 maxValue 用科學計數法(即為下方 normalize 函式)表示
將 minValue 表示為 保持指數級一致。maxValue = 1.07 * 10^2,minValue = 0 * 10^2。function normalize(x) {
if (x === 0) {
return [0, 0];
}
var exp = Math.floor(Math.log10(x));
var man = x / Math.pow(10, exp);
return [man, exp];
}
複製程式碼
降低數量級後將 normalMinValue 向下取整為 0,normalMaxValue 向上取整為 2,求解 exponent = 2 下的整數間隔,為 [0, 0.5, 1, 1.5, 2]
。原始碼對於 range 的多個值進行了特殊判讀,讀者可思考一下是否有更優的實現。
function getRangeIntervals(max, min) {
let upperBound = Math.ceil(max);
let lowerBound = Math.floor(min);
let range = upperBound - lowerBound;
let noOfParts = range;
let partSize = 1;
// To avoid too many partitions
if (range > 5) {
if (range % 2 !== 0) {
upperBound++;
// Recalc rangerange
range = upperBound - lowerBound;
}
noOfParts = range / 2;
partSize = 2;
}
// Special case: 1 and 2
if (range <= 2) {
noOfParts = 4;
partSize = range / noOfParts;
}
// Special case: 0
if (range === 0) {
noOfParts = 5;
partSize = 1;
}
let intervals = [];
for (var i = 0; i <= noOfParts; i++) {
intervals.push(lowerBound + partSize * i);
}
return intervals;
}
複製程式碼
最後根據 10^2 進行還原,並補齊負數部分,最終座標軸結果為 [-50, 0, 50, 100, 150, 200]
。
Frappé Charts 在浮點數計算中會遇到精度問題,例如:#79、#83,可以使用 nefe/number-precision 來處理。
動畫
Frappé Charts 中,當圖表資料發生變化時,圖表會重新執行 set_up 來計算與繪製圖表中的各個元素,在計算過程中會對需要執行動畫元素的新值與當前值進行差異計算,將結果統一加入 elements_to_animate。在 runSVGAnimation 中對於元素的各個動畫屬性構建 <animate>
或 <animateTransform>
,<animateTransform>
主要解決了 <animate>
不適合旋轉、平移、縮放或傾斜變換的問題。最終構建完成 anim_svg 後,會暫時移除新的 this.svg,插入 anim_svg 完成動畫後,再將 this.svg 重新插入。
run_animation() {
let anim_svg = runSVGAnimation(this.svg, this.elements_to_animate);
this.chart_wrapper.removeChild(this.svg);
this.chart_wrapper.appendChild(anim_svg);
setTimeout(() => {
this.chart_wrapper.removeChild(anim_svg);
this.chart_wrapper.appendChild(this.svg);
}, 250);
}
複製程式碼
DOM & SVG & ToolTip
上文提到 Frappé Charts 亮點之一就是零依賴,而 DOM 與 SVG 的操作確是必不可少的。無法藉助類似 JQuery、SVG.js 這樣的工具庫,那麼就必須由我們自己來實現。你是否還記得 You-Dont-Need-jQuery?其實一般的操作我們也完全不必去借助工具庫。
Frappé Charts 抽象了 dom.js 與 draw.js,進行了簡單封裝,但在一些做法上我們認為並不妥當,存在改進空間,例如:為了讓 SVG 的 text 相對於 line 居中,在 makeYLine 函式中寫死了 dy 的值。
function makeYLine(...) {
let line = createSVG('line', {
x1: startAt,
x2: width,
y1: 0,
y2: 0
});
let text = createSVG('text', {
x: textEndAt,
y: 0,
dy: '.32em',
...
});
...
}
複製程式碼
ToolTip 是圖表中不可獲缺的輔助資訊,上文提到在核心流程中會有 make_tooltip 函式,它將例項化一個SvgTip,SvgTip 會在圖表容器下插入一個隱藏的 div,之後執行不同圖表的 bind_tooltip,對圖形的 mouseenter 事件進行監聽,一旦觸發後,將相應位置資訊與圖形資料資訊傳給 SvgTip,SvgTip 完成最終顯示。
寫在最後
優點 | 缺點 | |
---|---|---|
Frappé Charts | 1. 設計簡潔 2. 配置方便,使用成本低 3. 無外部依賴 4. 使用 ES6 |
1. 無單元測試與整合測試 2. 複雜圖表能力弱 3. 擴充套件能力弱 |
對於 Frappé Charts,我們並不認為它是一個非常完美的庫。簡潔的設計與較低的使用成本,無法掩蓋它在圖表支援能力、擴充套件能力與穩定性上的不足,不過在即將到來的 0.1.0 版本中,我們也看到作者對圖表元件進行了一些重構,更貼近於 OOP。BaseChart 中的主流程將得到重新梳理,更好地遵循生命週期。混合圖表與多重座標軸也將得到支援。
Frappé Charts 提供的基礎圖表構建思路值得我們借鑑,基礎圖表研發看似簡單,卻需要我們考慮非常多的細節,作為一個新興圖表庫,沒有太多歷史包袱,學習成本也相對較低,有興趣的讀者也可以自行嘗試閱讀原始碼。當前 Frappé Charts 還在不斷更新與迭代,我們也會保持持續關注,如果想來和我們一起研究視覺化,歡迎投遞簡歷 dancang.hj@alibaba-inc.com