估計不少人會問,如今開源世界裏的圖表庫多如牛毛,爲何本身還要再弄個圖表庫呢?前端
整個系列裏做者會帶着你們一塊兒完成一個從0到1圖表庫的開發,歡迎來這裏踊躍拍磚⚽⚽⚽⚽git
工程分支github
注:文章比較長,涉及源碼分析,建議收藏一波,細細品味☕☕☕web
在具體開始擼代碼以前,咱們須要想清楚圖表的組成部分,這個庫的架構是怎樣的,開發者如何使用等等,形象點說就是咱們先弄一個施工圖出來typescript
限於咱們的圖表如今還沒整出來,咱們走一次抽象派,見下圖npm
這是圖展現了一個最基本的圖表的組成部分(咱們由淺入深),它包含如下部分:編程
下面這個動圖就是咱們本次要實現的效果,包含了X軸、Y軸、折線圖、柱形圖和動畫canvas
選擇typescript,好處有一下幾點bash
若是你對typescript不是很熟悉,能夠去官網中文官網進行更深刻的瞭解架構
在具體分析源碼以前咱們先介紹下三個概念
刻度與數字一一對應(unitWidth = (軸長度 / 39),tickUnit = 1,tickWidth = unitWidth)
這種處理沒問題,可是當40變成100、1000甚至10000的時候,刻度會變得密密麻麻,文字也會相互重疊跨越多個值標識一個刻度(unitWidth = (軸長度 / 39),tickUnit = 2,tickWidth = 2 * unitWidth)
能夠看出這裏跨越2個值出現一個刻度,這樣即使10000的數據,咱們只要把這個跨越值(我命名爲tickUnit)調整爲1000,依舊能很好的顯示對於柱狀圖咱們須要把柱子和label顯示在刻度之間(tickUnit = 1,unitWidth = (軸長度 / (39 + tickUnit)),tickWidth = unitWidth,並設置了boundaryGap)
對比刻度與數字一一對應,這裏的刻度數多了個1,而且每一個數值都顯示在兩個刻度之間假設dLen = 數據長度 - 1; 咱們能夠總結出
根據以上的分析,咱們開發了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;
}
複製代碼
AxisTick和 AxisLabel 都是直接傳入的參數分別繪製線條和點,比較簡單,不展開講解
Axis實現了軸的繪製,可是針對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也有本身的邏輯,須要根據數據的最大值和最小值自動計算整形刻度值 假設如今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
//目前實現得比較粗暴,後續還會再完善
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
畫折現圖的核心思想
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);
})
}
}
複製代碼
對Line和Point有興趣的同窗能夠分別點擊進去看,基本上就是根據參數繪製線條和點,基本看看就能看懂,Line裏能夠看看怎麼實現光滑畫圖, Point能夠看看怎麼畫不一樣形狀的點,仍是頗有意思的😊
固然若是你有更強的意願,還能夠去實現其餘類型的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;
},
...
}
複製代碼
每一個函數上都是四個參數
若是你想體驗緩動函數,能夠點擊這裏體驗
/* 保證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是怎麼實現的
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);
}
}
複製代碼
前面介紹了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的
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
});
})
複製代碼
/* 代碼看起來仍是很簡單的,根據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
複製代碼
npm run dev
複製代碼
接下來咱們會繼續完善動畫,並補充事件系統,盡請期待
FE One