帶你從0開發圖表庫系列-初具雛形

FEOne發表於2019-04-24

估計很多人會問,現在開源世界裡的圖表庫多如牛毛,為什麼自己還要再弄個圖表庫呢?

  • 開發庫很多不假,但是成熟的框架都是大而全的,學習成本高,而實際業務中使用的圖表都是比較簡單的,尤其是移動端更是要求精簡,但即便簡單的圖表也會摻雜個性化的需求,這個時候受框架的限制你會有一種無力感
  • 這時候如果你自己有一一套基礎圖表庫,既能滿足日常的業務開發,又能滿足老闆對視覺化個性化定製,會讓你覺得生成如此愜意~~
  • 當然如果最後這個庫開發失敗了,就權當學習好了??

整個系列裡作者會帶著大家一起完成一個從0到1圖表庫的開發,歡迎來這裡踴躍拍磚⚽⚽⚽⚽

工程分支

注:文章比較長,涉及原始碼分析,建議收藏一波,細細品味☕☕☕

在具體開始擼程式碼之前,我們需要想清楚圖表的組成部分,這個庫的架構是怎樣的,開發者如何使用等等,形象點說就是我們先弄一個施工圖出來

圖表組成分析

限於我們的圖表現在還沒整出來,我們走一次抽象派,見下圖

帶你從0開發圖表庫系列-初具雛形

這是圖展示了一個最基本的圖表的組成部分(我們由淺入深),它包含以下部分:

  • X軸
  • Y軸
  • 繪製區域
  • 各種型別圖
  • 圖例
  • 提示資訊
  • 輔助元素
  • 標題、副標題

初步建模

帶你從0開發圖表庫系列-初具雛形

Demo演示

下面這個動圖就是我們本次要實現的效果,包含了X軸、Y軸、折線圖、柱形圖和動畫

帶你從0開發圖表庫系列-初具雛形

程式語言選擇

選擇typescript,好處有一下幾點

  • 增強程式碼的可讀性和可維護性
  • 增強了編輯器和 IDE 的功能,包括程式碼補全、介面提示、跳轉到定義、重構等
  • 未來開發者使用的時候能夠做到配置自動提示

如果你對typescript不是很熟悉,可以去官網中文官網進行更深入的瞭解

Coding

座標軸

在具體分析原始碼之前我們先介紹下三個概念

  1. unitWidth 軸的基礎距離單位(用軸長除以值的個數得到)
  2. tickUnit 刻度之間跨越的值
  3. tickWidth 刻度之間的距離,一般是unitWidth的整數倍,這樣才能保證tick之間正好可以容納整數個值 假設對應X軸的資料是1-40的數字,能夠將軸評分成39份 接下來我們來看看下面這幾個場景
  • 刻度與數字一一對應(unitWidth = (軸長度 / 39),tickUnit = 1,tickWidth = unitWidth)

    最簡單
    這種處理沒問題,但是當40變成100、1000甚至10000的時候,刻度會變得密密麻麻,文字也會相互重疊

  • 跨越多個值標識一個刻度(unitWidth = (軸長度 / 39),tickUnit = 2,tickWidth = 2 * unitWidth)

    splited
    可以看出這裡跨越2個值出現一個刻度,這樣即便10000的資料,我們只要把這個跨越值(我命名為tickUnit)調整為1000,依舊能很好的顯示

  • 對於柱狀圖我們需要把柱子和label顯示在刻度之間(tickUnit = 1,unitWidth = (軸長度 / (39 + tickUnit)),tickWidth = unitWidth,並設定了boundaryGap)

    boundaryGap
    對比刻度與數字一一對應,這裡的刻度數多了個1,並且每個數值都顯示在兩個刻度之間

假設dLen = 資料長度 - 1; 我們可以總結出

  • unitWidth = length / (dLen + ( boundaryGap ? tickUnit : 0)); //每個資料點之間的距離
  • tickWidth = tickUnit * unitWidth; //每個tick的寬度
  • 同時tick和label是分別進行座標定位

根據以上的分析,我們開發了Axis基礎類

Axis

...
constructor(opt:AxisOpt){
    this.data = (opt.data || []) as string[] //label資料
    ...
    /*x,y為軸線的中心 */
    this.x = x; 
    this.y = y; 
    const dLen = this.data.length - 1
    const rSplitNumber =  splitNumber || dLen; //軸要分成幾段
    this.tickUnit = Math.ceil(dLen / rSplitNumber); // tick之間包含的資料點個數,不包含最後一個
    this.unitWidth = length / (dLen + ( this.boundaryGap ?  this.tickUnit : 0)); //每個資料點之間的距離
    this.tickWidth = this.tickUnit * this.unitWidth; //每個tick的寬度
    this.length = length; //軸長度
    ... //省略掉一些配置引數的解析 
    this.parseStartAndEndPoint(mergeOpt); //解析軸線的起點start和終點end
    if(horizontal){
        this.textAlign = "center";
        this.textBaseline = reverse ? "bottom" : "top";
        this.createHorizontatickAndLabels(mergeOpt) //建立橫向的刻度和label
    }else{
        this.textAlign = reverse ? "left" : "right";
        this.textBaseline = "middle";
        this.createVerticatickAndLabels(mergeOpt); //建立垂直的刻度和label
    }
}
...
複製程式碼

核心邏輯還是計算tickUnit,unitWidth,tickWidth,然後通過parseStartAndEndPoint解析軸線的起點和終點,然後根據horizontal建立水平或者垂直的tick和label,接下來我們通過createHorizontatickAndLabels看看這個過程

createHorizontatickAndLabels(opt:AxisOpt){
    const ticks = []; //刻度列表
    const labels = []; //label列表
    let count = 0;
    let i:number;
    const {boundaryGap} = this;
    const {x,y,reverse,tickLen,length,labelBase,offset} = opt;
    const baseX = (x-length/2) //起點的x值
    /* 設定了boundaryGap,則表示在在軸的左右兩側分別保留半個刻度長度的間隔 */
    let dataLen:number,baseLabelX:number; 
    if(boundaryGap){
        dataLen = this.data.length + 1
        baseLabelX = this.tickWidth / 2 //label的起始X偏移半個刻度寬度
    }else{
        dataLen = this.data.length
        baseLabelX = 0
    }
    const reverseNum = reverse ? -1 : 1; //刻度是向內還是向外
    for(i = 0; i < dataLen; i+= this.tickUnit){ //根據前面計算的tickUnit建立對應刻度和label
        const newX = baseX + count  * this.tickWidth; //計算當前刻度的x
        let start = y,end = y + tickLen * reverseNum; //計算刻度的起始Y和終點Y
        let endPos = labelBase + tickLen * reverseNum  //根據labelBase計算label的Y值
        const value = this.data[i];
        ticks.push(new AxisTick({x:newX,y:start},{x:newX,y:end},this.axisTickOpt))
        labels.push(new AxisLabel(newX + baseLabelX,endPos + offset * reverseNum,value));
        count++;
    }
    this.labels = labels;
    this.ticks = ticks;
}
複製程式碼

AxisTickAxisLabel 都是直接傳入的引數分別繪製線條和點,比較簡單,不展開講解

XAxis

Axis實現了軸的繪製,但是針對X軸還有自己的邏輯

  • 根據繪製區域計算軸線的x
  • 根據Y軸的0刻度計算y
  • 根據資料的index獲取對應的x座標

因此我們封裝了XAxis根據自己的邏輯去生成資料和配置,然後使用Axis去繪製

 class XAxis implements IXAxis{
    ...
    /* 
    * _area 表示已X軸和Y軸為邊的serial繪製區域
    * _yaxisList 表示所有的Y軸列表
    * option x軸的配置選項
    */
    constructor(private _area:Area, private _yaxisList:Array<YAxis> ,option:XAxisOption) {
       this.option = option
    }
    getZeroY(){
        for(let i = 0; i < this._yaxisList.length; i++){
            const yAxis = this._yaxisList[i];
            const ret = yAxis.getYByValue(0);
            if(ret != null) return ret;
        }
    }
    init(){
        const {_area,option} = this;
        const {isTop,axisOpt} = option;
        let labelBase = isTop ? _area.top : _area.bottom //X軸顯示在上面還是下面
        let y = this.getZeroY(); //獲取Y軸上0對應的Y座標
        if(y == null){ y = labelBase }
        const x = _area.x
        this.axis = new Axis(assign({boundaryGap:true},axisOpt,{
            x,y,labelBase,
            length:_area.width //serial繪製區域的寬度就是軸的長度
        }))
    }
    getXbyIndex(index:number,baseLeft:number):number{ //根據資料點的下標獲取對應的x座標
        const boundaryGapLengh = this.axis.boundaryGap ? this.axis.tickWidth / 2 : 0
        return boundaryGapLengh + index * this.axis.unitWidth + baseLeft
    }
    draw(painter:IPainter){
        this.axis.draw(painter);
    }
}
複製程式碼

YAxis

YAxis也有自己的邏輯,需要根據資料的最大值和最小值自動計算整形刻度值 假設現在Y軸的最大值是280,最小值是0,然後需要把Y軸分成10段,見下圖

帶你從0開發圖表庫系列-初具雛形

你會發現這個刻度值看起來很亂,我們的期望是這樣的

帶你從0開發圖表庫系列-初具雛形

我們進入到原始碼分析

class YAxis implements IYAxis{
    ...
    constructor(private _area:Area,private _opt:{
        isRight?:boolean,
        max:number,
        axisIndex:number,
        min:number,
        axisOpt:YAxisOpt}) {}
    init(){
        const {_area,_opt} = this;
        const {isRight,axisOpt,max,min} = _opt;
        const ret = getUnitsFromMaxAndMin(max,min) //重新計算最大值和最小值,邏輯下面會詳細分析
        this.max = ret.max;
        this.min = ret.min;
        this.range = this.max - this.min; //Y軸的值範圍
        let labelBase = isRight ? _area.right : _area.left, y = _area.y 
        this.axis = new Axis(assign({},axisOpt,{
            x:labelBase,y,labelBase,data:ret.data,horizontal:false,
            boundaryGap:false,
            length:_area.height
        }) as AxisOpt)
    }
    getYByValue(val:number):number{ //根據值獲取Y座標,供serial用
        const {range,_area,min,max} = this;
        if(max - val >= 0 && val - min >= 0){
            return _area.bottom - (val - min) * _area.height / range
        }
        return null;
    }
    draw(painter:IPainter){
        this.axis.draw(painter);
    }
}
複製程式碼

最大值和最小值地修正以及刻度的生成都是在getUnitsFromMaxAndMin完成的,所以我們需要看看getUnitsFromMaxAndMin

getUnitsFromMaxAndMin分析

//目前實現得比較粗暴,後續還會再完善
function getUnitsFromMaxAndMin(max:number,min:number,splitNumber:number = 10){
    /*將最大和最小值處理成能被10整除的*/
    max = (Math.floor(max / 10 ) + 1) * 10
    min = Math.floor(min / 10 )  * 10
    const range = max - min; //計算差值
    /* 根據差值分割成splitNumber個單位,最終就是Y軸的刻度 */
    let unit = Math.ceil(range / splitNumber);
    unit = (Math.floor(unit / 10) + 1) * 10 //把刻度之間的值跨度也調整為10的整數倍
    let data = [],tmp = min;
    while(tmp < max){
        data.push(tmp);
        tmp += unit;
    }
    data.push(tmp);
    max = tmp;
    return {
        max,
        min,
        data
    }
}
複製程式碼

至此我們終於完成了座標軸的分析和程式碼編寫,接下來我們要來分析根據座標軸如何構建我們的Serial

LineSerial(折線圖)?

畫折現圖的核心思想

  1. 確定繪製區域 所以LineSerial需要有自己的Area
  2. 將資料轉化成一個個的點 每個資料的特徵是有值和下標,通過Y軸可以把值轉換成y座標,通過X軸可以把下標轉換成x座標,所以LineSerial依賴對應的X軸和Y軸
  3. 將點連成線 有了資料,需要有檢視來把這些點連成線,這裡我們又封裝了Line
class LineSerial implements ILazyWidget{
    area:Area //Serial的繪製區域
    xAxis:IXAxis //對應的X軸
    yAxis:IYAxis //對應的Y軸
    lineView:Line //負責繪製線條
    tickPointList:Array<Point> //負責繪製對應刻度的點
    option:LineSerailOption //配置引數
    constructor(option:LineSerailOption){
        this.option = option
    }
    init(){
        const {area,data,xAxis,yAxis,lineStyle,pointStyle} = this.option
        this.area = area;
        this.xAxis = xAxis;
        this.yAxis = yAxis;
        const tickPoints = []
        const newData = data.map((value,index)=>{
            const isTickPoint = index % xAxis.axis.tickUnit === 0; //標記當前點是否正好對應刻度
            const posX = xAxis.getXbyIndex(index,area.left) //根據下標獲取x座標
            const posY = yAxis.getYByValue(value) //根據值獲取y座標
            const startX = posX, startY= yAxis.getYByValue(0);
            /* 這塊是動畫的計算基礎,有初始態和終態 */
            const pos = {
                x:startX,//當前x
                y:startY, //當前y
                targetX:posX, //最終的x
                targetY:posY, //最終的x
                startX : startX, //初始的x
                startY: startY //初始的x
            }
            if(isTickPoint){
                //建立於刻度對應的資料點
                const tickPoint = new Point(assign({},pointStyle || {},pos))
                // 加入動畫
                Animation.addAnimationWidget(tickPoint)
                tickPoints.push(tickPoint)
    
            }
            return pos 
        })
        this.tickPointList = tickPoints
        //建立線條
        this.lineView = new Line(newData,lineStyle) //根據一系列包含x,y座標的點繪製具體的線條
        Animation.addAnimationWidget(this.lineView)
    }
    draw(painter:IPainter){
        this.lineView.draw(painter);
        this.tickPointList.forEach((tickPoint)=>{
            tickPoint.draw(painter);
        })
    }
}
複製程式碼

LinePoint有興趣的同學可以分別點選進去看,基本上就是根據引數繪製線條和點,基本看看就能看懂,Line裡可以看看怎麼實現光滑畫圖, Point可以看看怎麼畫不同形狀的點,還是很有意思的?

BarSerial感興趣可以點這裡,按照上面的思路去分析

當然如果你有更強的意願,還可以去實現其他型別的Serial來提交PR

細心的同學一定注意到了每個點都會生成startX、startY、targetX、targetY、x、y,start表示初始態,target表示目標態,有了這些資訊我們才能去生成動畫,這塊接下來就會講到

動畫

注: 本次動畫的實現只是臨時方案,後續會重構 動畫核心就是已知初始態和目標態,通過緩動函式生成動畫幀,並且達到60fps,就形成了前面我們看到的動畫效果

緩動函式

const effects = {
    ...
    easeInQuad: function ( t, b, c, d) {
    return c*(t/=d)*t + b;
    },
    easeOutQuad: function ( t, b, c, d) {
        return -c *(t/=d)*(t-2) + b;
    },
    ...
}
複製程式碼

每個函式上都是四個引數

  • t 動畫播放時間 (當前時間 - 動畫開始時間)
  • b 初始值
  • c 變化值 (目標值 - 初始值)
  • d 動畫持續時間

如果你想體驗緩動函式,可以點選這裡體驗

動畫播放


/* 保證60fps */
const requestAnimFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback) {
    return window.setTimeout(callback, 1000 / 60);
}

startAnimation(painter:IPainter,draw:()=>void){
    ...
    /*animationItemList 儲存了所有需要動畫的圖形*/
    animationItemList.forEach((item)=>{
        item.widget.onStart();  
    })
    let startTm = Date.now();
    const callback = function(){
        const diffTm = (Date.now() - startTm); //對應引數t
        const reseverdWidgets = [];
        for(let i = 0; i < animationItemList.length; i++){
            const {widget,option} = animationItemList[i];
            const {duration} = option;
            if(diffTm > duration){
                /* 圖形完成狀態改變 */
                widget.transtion(duration,duration)
                widget.onComplete();
            }else{
                const ret = widget.transtion(diffTm,duration);
                if(ret !== false){
                    reseverdWidgets.push(animationItemList[i]);
                }else{
                    widget.onComplete();
                }
            }
        }
        /* 清除畫板 */
        painter.clear();
        /* 所有圖形重新繪製 */
        draw();
        Animation.animationItemList = reseverdWidgets;
        if(reseverdWidgets.length > 0){
            /* 還有沒結束動畫的圖形,需要繼續執行 */
            requestAnimFrame(callback)
        }else{
            Animation.animationFlag = false;
        }
    }
    ...
    requestAnimFrame(callback)
    ...
}
複製程式碼

根據上面的原始碼,startAnimation只是提供了比較初級的動畫框架,動畫的具體實現是由每個widget自己去實現的,核心的介面包括onStart,transtion和onComplete,這裡我們來看看Point是怎麼實現的

Point

class Point implements IPointWidet{
    onComplete(){
        //把當前的x,y當做後面動畫的初始狀態
        this.startX = this.x;
        this.startY = this.y;
    }
    onStart(){
        //分別計算x,y的變化值
        this.diffX = this.targetX - this.startX;
        this.diffY = this.targetY - this.startY;
    } 
    transtion(tm:number,duration:number):boolean | void{
        //通過緩動函式計算新的x,y
		this.x = Easing.easeInOutCubic(tm,this.startX,this.diffX,duration);
        this.y = Easing.easeInOutCubic(tm,this.startY,this.diffY,duration);
    }
}
複製程式碼

fchart

前面介紹了XAxis,YAxis和LineSerial,都還是各自獨立的個體,這裡我要介紹的是如何把這些有機的結合起來最終形成我們畫出來的圖表

export default class Fchart{
    XAxisList:XAxis[] = []
    YAxisList:YAxis[] = []
    series:Array<ILazyWidget>
    painter:Painter
    paddingTop:number
    paddingRight:number
    paddingBottom:number
    paddingLeft:number
    paintArea:Area
    constructor(canvas:HTMLCanvasElement,option:ChartOption){
        const mergeOption = assign({},DEFAULT_CHART_OPTION,option);
        /* 建立畫筆 */
        this.painter = new Painter(canvas,mergeOption);
        const {padding,series,xAxis,yAxis} = mergeOption;
        this.paddingTop = padding[0];
        this.paddingRight = padding[1];
        this.paddingBottom = padding[2];
        this.paddingLeft = padding[3];
        const {width,height} = this.painter; 
        const centerX = ((width - this.paddingRight) + this.paddingLeft) / 2
        const centerY = ((height - this.paddingBottom) + this.paddingTop) / 2
        //根據padding、width、height計算serial繪製區域
        this.paintArea = new Area({
            x:centerX,
            y:centerY,
            width:width - (this.paddingLeft + this.paddingRight),
            height:height - (this.paddingTop + this.paddingBottom)
        })
        //建立X軸和Y軸
        this.createXYAxises(series,xAxis,yAxis);
        //建立serial
        this.createSerialCharts(series);
        //初始化
        this.init();
        //繪圖
        this.draw(); 
        //開啟動畫
        Animation.startAnimation(this.painter,this.draw.bind(this));
    }
    createXYAxises(series:Array<SerialOption>,xAxis:AxisOpt,yAxis:AxisOpt){
        ...
    }
    createSerialCharts(series:Array<SerialOption>,){
        ...
    }
    init(){
        this.YAxisList.forEach((yAxis)=>{
            yAxis.init();
        }) //y軸需要先初始化,x軸依賴y軸去找0點Y值
        this.XAxisList.forEach((xAxis)=>{
            xAxis.init();
        })
        this.series.forEach((serial)=>{
            serial.init();
        })
        
    }
    draw(){
        const {painter} = this;
        //繪製x軸
        this.XAxisList.forEach((xAxis)=>{
            xAxis.draw(painter)
        })
        //繪製y軸
        this.YAxisList.forEach((yAxis)=>{
            yAxis.draw(painter)
        })
        //繪製serial
        this.series.forEach((serial)=>{
            serial.draw(painter)
        })
    }
}
複製程式碼

講解完主流程後,我們來看看createXYAxises中是如何建立X和Y軸的,createSerialCharts是如何建立Serial的

createXYAxises

const yAxisItemList = [],xAxisItemList = []
//這一部分是根據傳入的serial,計算不同序號下y軸的最大值和最小值
series.forEach((serial)=>{
    /*yAxisIndex 對應Y軸的序號 對應X軸的序號, 這裡我們看出fchart是支援多個軸的*/
    const {data,yAxisIndex = 0,xAxisIndex = 0} = serial
    let yAxisItem = yAxisItemList[yAxisIndex];
    let xAxisItem = xAxisItemList[xAxisIndex]; 
    ...
    let {max,min} = maxAndMin(data,yAxisItem);
    if(min > 0) {min = 0}
    yAxisItem.min = min
    yAxisItem.max = max
    xAxisItem.min = min
    xAxisItem.max = max
})
/* 建立y軸 */
this.YAxisList = yAxisItemList.map((item,index)=>{
    return new YAxis(this.paintArea,{
        max:item.max,
        min:item.min,
        axisIndex:index,
        axisOpt:(yAxis || {}) as AxisOpt
    });
})
/* 建立x軸 */
this.XAxisList = xAxisItemList.map((item,index)=>{
    return new XAxis(this.paintArea,this.YAxisList,{
        axisIndex:index,
        axisOpt:(xAxis || {}) as AxisOpt
    });
})
複製程式碼

createSerialCharts

/* 程式碼看起來還是很簡單的,根據type的不同建立對應的serial */
 const {colors} = Global.defaultConfig;
this.series = series.map((serial,index)=>{
    const {yAxisIndex = 0,xAxisIndex = 0} = serial
    const yAxis = this.YAxisList[yAxisIndex];
    const xAxis = this.XAxisList[xAxisIndex];
    const {type} = serial;
    ...
    const baseOpt = assign({},serial,{
        area:this.paintArea,
        xAxis:xAxis,
        yAxis:yAxis
    })
    if(type === 'line'){
        ...
        return new LineSerial(baseOpt)
    }else if(type === 'bar'){
        ...
        return new BarSerial(baseOpt)
    }
})
複製程式碼

開發

本庫選用parcel開箱即用的解決方案,不熟悉的同學如果對parcel感興趣可以去官網瞭解瞭解,沒興趣也沒關係,按照以下指示也是能跑起demo來的

安裝依賴

npm install
複製程式碼

開發預覽demo

npm run dev
複製程式碼

後續

接下來我們會繼續完善動畫,並補充事件系統,盡請期待

倉庫地址

專欄其他文章

關注我們

帶你從0開發圖表庫系列-初具雛形
FE One

關注我們的公眾號FE One,會不定期分享JS函數語言程式設計、深入Reaction、Rxjs、工程化、WebGL、中後臺構建等前端知識

相關文章