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

估計不少人會問,如今開源世界裏的圖表庫多如牛毛,爲何本身還要再弄個圖表庫呢?前端

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

整個系列裏做者會帶着你們一塊兒完成一個從0到1圖表庫的開發,歡迎來這裏踊躍拍磚⚽⚽⚽⚽git

工程分支github

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

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

圖表組成分析

限於咱們的圖表如今還沒整出來,咱們走一次抽象派,見下圖npm

這是圖展現了一個最基本的圖表的組成部分(咱們由淺入深),它包含如下部分:編程

  • X軸
  • Y軸
  • 繪製區域
  • 各類類型圖
  • 圖例
  • 提示信息
  • 輔助元素
  • 標題、副標題

初步建模

Demo演示

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

編程語言選擇

選擇typescript,好處有一下幾點bash

  • 加強代碼的可讀性和可維護性
  • 加強了編輯器和 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段,見下圖

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

咱們進入到源碼分析

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
複製代碼

後續

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

倉庫地址

專欄其餘文章

關注咱們


FE One

關注咱們的公衆號FE One,會不按期分享JS函數式編程、深刻Reaction、Rxjs、工程化、WebGL、中後臺構建等前端知識
相關文章
相關標籤/搜索