估计不少人会问,如今开源世界里的图表库多如牛毛,为何本身还要再弄个图表库呢?前端
整个系列里做者会带着你们一块儿完成一个从0到1图表库的开发,欢迎来这里踊跃拍砖⚽⚽⚽⚽git
工程分支github
注:文章比较长,涉及源码分析,建议收藏一波,细细品味☕☕☕web
在具体开始撸代码以前,咱们须要想清楚图表的组成部分,这个库的架构是怎样的,开发者如何使用等等,形象点说就是咱们先弄一个施工图出来typescript
限于咱们的图表如今还没整出来,咱们走一次抽象派,见下图npm
这是图展现了一个最基本的图表的组成部分(咱们由浅入深),它包含如下部分:编程
下面这个动图就是咱们本次要实现的效果,包含了X轴、Y轴、折线图、柱形图和动画canvas
选择typescript,好处有一下几点bash
若是你对typescript不是很熟悉,能够去官网中文官网进行更深刻的了解架构
在具体分析源码以前咱们先介绍下三个概念
刻度与数字一一对应(unitWidth = (轴长度 / 39),tickUnit = 1,tickWidth = unitWidth)
跨越多个值标识一个刻度(unitWidth = (轴长度 / 39),tickUnit = 2,tickWidth = 2 * unitWidth)
对于柱状图咱们须要把柱子和label显示在刻度之间(tickUnit = 1,unitWidth = (轴长度 / (39 + tickUnit)),tickWidth = unitWidth,并设置了boundaryGap)
假设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