温馨提示×

温馨提示×

您好,登录后才能下订单哦!

密码登录×
登录注册×
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》

在前端开发中如何通过Canvas实现K线图

发布时间:2022-02-23 15:47:21 来源:亿速云 阅读:452 作者:iii 栏目:开发技术

今天小编给大家分享一下在前端开发中如何通过Canvas实现K线图的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来了解一下吧。

1.对于实现的话,我所考虑的有两个方向,一是类似于Highcharts等插件的实现方式 -- svg,一是HTML5的canvas。

SVG 是一种使用 XML 描述 2D 图形的语言。 Canvas 通过 JavaScript 来绘制 2D 图形。 Canvas 是逐像素进行渲染的。

经过上面的比较不难发现, SVG 更适用于偏静态,渲染频率不高的场景,所以这种要实现实时报价更新绘制的情况只能选择 canvas

2. 实现哪些需求

历史报价实时报价 绘制图表

支持 拖拽 查看历史时间段的报价图表

支持鼠标 滚轮 和触摸板 双指 操作放大或缩小图表

支持鼠标指针 移动 查看鼠标位置报价

3. 代码实现过程

1. 准备工作

/**
 * K-line - K线图渲染函数
 * Date: 2019.12.18  Author: isnan
 */
const BLOCK_MARGIN = 2; //方块水平间距
const START_PRICE_INDEX = 'open_price'; //开始价格在数据组中的位置
const END_PRICE_INDEX = 'close'; //结束价格在数据组中的位置
const MIN_PRICE_INDEX = 'low'; //最小价格在数据组中的位置
const MAX_PRICE_INDEX = 'high'; //最大价格在数据组中的位置
const TIME_INDEX = 'time'; //时间在数据组中的位置
const LINE_WIDTH = 1; //1px 宽度 (中间线、x轴等)
const BOTTOM_SPACE = 40; //底部空间
const TOP_SPACE = 20; //顶部空间
const RIGHT_SPACE = 60; //右侧空间
let _addEventListener, _removeEventListener, prefix = ''; //addEventListener 浏览器兼容
function RenderKLine (id, /*Optional*/options) {
  if (!id) return;
  options = options || {};
  this.id = id;   //canvas box id
  // detect event model
  if (window.addEventListener) {
    _addEventListener = "addEventListener";
    _removeEventListener = "removeEventListener";
  } else {
    _addEventListener = "attachEvent";
    _removeEventListener = "detachEvent"
    prefix = "on";
  }
  // options params
  this.sharpness = options.sharpness;  // 清晰度 (正整数 太大可能会卡顿,取决于电脑配置 建议在2~5区间)
  this.blockWidth = options.blockWidth; // 方块的宽度 (最小为3,最大49 为了防止中间线出现位置偏差 设定为奇数,若为偶数则向下减1)
  this.buyColor = options.buyColor || '#F05452';  // color 涨
  this.sellColor = options.sellColor || '#25C875';  // color 跌
  this.fontColor = options.fontColor || '#666666';  //文字颜色
  this.lineColor = options.lineColor || '#DDDDDD';  //参考线颜色
  this.digitsPoint = options.digitsPoint || 2; //报价的digits (有几位小数)
  this.horizontalCells = options.horizontalCells || 5; //水平方向切割多少格子 (中间虚线数 = 5 - 1)
  this.crossLineStatus = options.crossLineStatus || true; //鼠标移动十字线显示状态

  //basic params
  this.totalWidth = 0;  //总宽度
  this.movingRange = 0; //横向移动的距离 取正数值,使用时再加负号
  this.minPrice = 9999999;
  this.maxPrice = 0; //绘制的所有数据中 最小/最大数据 用来绘制y轴
  this.diffPrice = 0;  //最大报价与最小报价的差值
  this.perPricePixel = 0; //每一个单位报价占用多少像素
  this.centerSpace = 0; //x轴到顶部的距离 绘图区域
  this.xDateSpace = 6;  //x轴上的时间绘制间隔多少组
  this.fromSpaceNum = 0;  //x轴上的时间绘制从第 (fromSpaceNum%xDateSpace) 组数据开始 
  this.dataArr = [];  //数据
  this.lastDataTimestamp = undefined; //历史报价中第一个时间戳, 用来和实时报价做比较画图
  this.buyColorRGB = {r: 0, g: 0, b: 0};
  this.sellColorRGB = {r: 0, g: 0, b: 0};
  
  this.processParams();
  this.init();
}

定义了一些常量和变量,生成一个 构造函数 ,接收两个参数,一个是id,canvas会在插入到这个id的盒子内,第二个参数是一些配置项,可选。

/**
 *    sharpness {number} 清晰度
 *    buyColor {string} color - 涨
 *    sellColor {string} color - 跌
 *    fontColor {string} 文字颜色
 *    lineColor {string} 参考线颜色
 *    blockWidth {number} 方块的宽度
 *    digitsPoint {number} 报价有几位小数
 *    horizontalCells {number} 水平方向切割几个格子
 *    crossLineStatus {boolean} 鼠标移动十字线显示状态
 */

2. init方法和canvas画布的翻转

RenderKLine.prototype.init = function () {
  let cBox = document.getElementById(this.id);
  // 创建canvas并获得canvas上下文
  this.canvas = document.createElement("canvas");
  if (this.canvas && this.canvas.getContext) {
    this.ctx = this.canvas.getContext("2d");
  }

  this.canvas.innerHTML = '您的当前浏览器不支持HTML5 canvas';
  cBox.appendChild(this.canvas);
  this.actualWidth = cBox.clientWidth;
  this.actualHeight = cBox.clientHeight;
  
  this.enlargeCanvas();
}
// 因为绘制区域超出canvas区域,此方法也用来代替clearRect 清空画布的作用
RenderKLine.prototype.enlargeCanvas = function () {
  this.canvas.width = this.actualWidth * this.sharpness;
  this.canvas.height = this.actualHeight * this.sharpness;
  this.canvas.style.height = this.canvas.height / this.sharpness + 'px';
  this.canvas.style.width = this.canvas.width / this.sharpness + 'px';
  this.centerSpace = this.canvas.height - (BOTTOM_SPACE + TOP_SPACE) * this.sharpness;
  // 将canvas原点坐标转换到右上角
  this.transformOrigin();
  // base settings
  this.ctx.lineWidth = LINE_WIDTH*this.sharpness;
  this.ctx.font = `${12*this.sharpness}px Arial`;
  // 还原之前滚动的距离
  this.ctx.translate(-this.movingRange * this.sharpness, 0);
  // console.log(this.movingRange);
}

init方法初始化了一个canvas,enlargeCanvas是一个替代clearRect的方法,其中需要注意的是 transformOrigin 这个方法,因为正常的canvas原点坐标在坐上角,但是我们需要绘制的图像是从右侧开始绘制的,所以我这里为了方便绘图,把整个canvas做了一次转换,原点坐标转到了右上角位置。

// 切换坐标系走向 (原点在左上角 or 右上角)
RenderKLine.prototype.transformOrigin = function () {
  this.ctx.translate(this.canvas.width, 0);
  this.ctx.scale(-1, 1);
}

这里有一点需要注意的是,虽然翻转过来绘制一些矩形,直线没什么问题,但是绘制文本是不行的,绘制文本需要还原回去,不然文字就是翻转过来的状态。如下图所示:

在前端开发中如何通过Canvas实现K线图 

3. 移动、拖拽、滚轮事件

//监听鼠标移动
RenderKLine.prototype.addMouseMove = function () {
  this.canvas[_addEventListener](prefix+"mousemove", mosueMoveEvent);
  this.canvas[_addEventListener](prefix+"mouseleave", e => {
    this.event = undefined;
    this.enlargeCanvas();
    this.updateData();
  });
  const _this = this;
  function mosueMoveEvent (e) {
    if (!_this.dataArr.length) return;
    _this.event = e || event;
    _this.enlargeCanvas();
    _this.updateData();
  }
}

//拖拽事件
RenderKLine.prototype.addMouseDrag = function () {
  let pageX, moveX = 0;
  this.canvas[_addEventListener](prefix+'mousedown', e => {
    e = e || event;
    pageX = e.pageX;
    this.canvas[_addEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  this.canvas[_addEventListener](prefix+'mouseup', e => {
    this.canvas[_removeEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  this.canvas[_addEventListener](prefix+'mouseleave', e => {
    this.canvas[_removeEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  
  const _this = this;
  function dragMouseMoveEvent (e) {
    if (!_this.dataArr.length) return;
    e = e || event;
    moveX = e.pageX - pageX;
    pageX = e.pageX;
    _this.translateKLine(moveX);
    // console.log(moveX);
  }
}

//Mac双指行为 & 鼠标滚轮
RenderKLine.prototype.addMouseWheel = function () {
  addWheelListener(this.canvas, wheelEvent);
  const _this = this;
  function wheelEvent (e) {
      if (Math.abs(e.deltaX) !== 0 && Math.abs(e.deltaY) !== 0) return; //没有固定方向,忽略
      if (e.deltaX < 0) return _this.translateKLine(parseInt(-e.deltaX)); //向右
      if (e.deltaX > 0) return _this.translateKLine(parseInt(-e.deltaX)); //向左
      if (e.ctrlKey) {
        if (e.deltaY > 0) return _this.scaleKLine(-1); //向内
        if (e.deltaY < 0) return _this.scaleKLine(1); //向外
      } else {
        if (e.deltaY > 0) return _this.scaleKLine(1); //向上
        if (e.deltaY < 0) return _this.scaleKLine(-1); //向下
      }
  }
}

滚轮事件 上一篇已经说过了,这里就是对不同情况做相应的处理;

鼠标移动事件 把event更新到 this 上,然后调用 updateData 方法,绘制图像即可。会调用下面方法画出十字线。

function drawCrossLine () {
  if (!this.crossLineStatus || !this.event) return;
  let cRect = this.canvas.getBoundingClientRect();
  //layerX 有兼容性问题,使用clientX
  let x = this.canvas.width - (this.event.clientX - cRect.left - this.movingRange) * this.sharpness;
  let y = (this.event.clientY - cRect.top) * this.sharpness;
  // 在报价范围内画线
  if (y < TOP_SPACE*this.sharpness || y > this.canvas.height - BOTTOM_SPACE * this.sharpness) return;
  this.drawDash(this.movingRange * this.sharpness, y, this.canvas.width+this.movingRange * this.sharpness, y, '#999999');
  this.drawDash(x, TOP_SPACE*this.sharpness, x, this.canvas.height - BOTTOM_SPACE*this.sharpness, '#999999');
  //报价
  this.ctx.save();
  this.ctx.translate(this.movingRange * this.sharpness, 0);
  // 填充文字时需要把canvas的转换还原回来,防止文字翻转变形
  let str = (this.maxPrice - (y - TOP_SPACE * this.sharpness) / this.perPricePixel).toFixed(this.digitsPoint);
  this.transformOrigin();
  this.ctx.translate(this.canvas.width - RIGHT_SPACE * this.sharpness, 0);
  this.drawRect(-3*this.sharpness, y-10*this.sharpness, this.ctx.measureText(str).width+6*this.sharpness, 20*this.sharpness, "#ccc");
  this.drawText(str, 0, y, RIGHT_SPACE * this.sharpness)
  this.ctx.restore();
}

拖拽事件pageX 的移动距离传递给 translateKLine 方法来实现横向滚动查看。

/**
 * 缩放图表 
 * @param {int} scaleTimes 缩放倍数
 *  正数为放大,负数为缩小,数值*2 代表蜡烛图width的变化度
 *  eg:  2 >> this.blockWidth + 2*2  
 *      -3 >> this.blockWidth - 3*2
 * 为了保证缩放的效果,
 * 应该以当前可视区域的中心为基准缩放
 * 所以缩放前后两边的长度在总长度中所占比例应该一样
 * 公式:(oldRange+0.5*canvasWidth)/oldTotalLen = (newRange+0.5*canvasWidth)/newTotalLen
 * diffRange = newRange - oldRange
 *           = (oldRange*newTotalLen + 0.5*canvasWidth*newTotalLen - 0.5*canvasWidth*oldTotalLen)/oldTotalLen - oldRange
 */
RenderKLine.prototype.scaleKLine = function (scaleTimes) {
  if (!this.dataArr.length) return;
  let oldTotalLen = this.totalWidth;
  this.blockWidth += scaleTimes*2;
  this.processParams();
  this.computeTotalWidth();
  let newRange = (this.movingRange*this.sharpness*this.totalWidth+this.canvas.width/2*this.totalWidth-this.canvas.width/2*oldTotalLen)/oldTotalLen/this.sharpness;
  let diffRange = newRange - this.movingRange;
  // console.log(newRange, this.movingRange, diffRange);
  this.translateKLine(diffRange);
}
// 移动图表
RenderKLine.prototype.translateKLine = function (range) {
  if (!this.dataArr.length) return;
  this.movingRange += parseInt(range);
  let maxMovingRange =  (this.totalWidth - this.canvas.width) / this.sharpness + this.blockWidth;
  if (this.totalWidth <= this.canvas.width || this.movingRange <= 0) {
    this.movingRange = 0;
  } else if (this.movingRange >= maxMovingRange) {
    this.movingRange = maxMovingRange;
  }
  this.enlargeCanvas();
  this.updateData();
}

4. 核心方法 updateData

所有的绘制过程都是在这个方法中完成的,这样无论想要什么操作,都可以通过此方法重绘canvas来实现,需要做的只是改变原型上的一些属性而已,比如想要左右移动,只需要把 this.movingRange 设置好,再调用 updateData 就完成了。

RenderKLine.prototype.updateData = function (isUpdateHistory) {
  if (!this.dataArr.length) return;
  if (isUpdateHistory) {
    this.fromSpaceNum = 0;
  }
  // console.log(data);
  this.computeTotalWidth();
  this.computeSpaceY();
  this.ctx.save();
  // 把原点坐标向下方移动 TOP_SPACE 的距离,开始绘制水平线
  this.ctx.translate(0, TOP_SPACE * this.sharpness);
  this.drawHorizontalLine();
  // 把原点坐标再向左边移动 RIGHT_SPACE 的距离,开始绘制垂直线和蜡烛图
  this.ctx.translate(RIGHT_SPACE * this.sharpness, 0);
  // 开始绘制蜡烛图
  let item, col;
  let lineWidth = LINE_WIDTH * this.sharpness,
      margin = blockMargin = BLOCK_MARGIN*this.sharpness,
      blockWidth = this.blockWidth*this.sharpness;//乘上清晰度系数后的间距、块宽度
  let blockHeight, lineHeight, blockYPoint, lineYPoint; //单一方块、单一中间线的高度、y坐标点
  let realTime, realTimeYPoint; //实时(最后)报价及y坐标点
  for (let i=0; i<this.dataArr.length; i++) {
    item = this.dataArr[i];
    if (item[START_PRICE_INDEX] > item[END_PRICE_INDEX]) {
      //跌了 sell
      col = this.sellColor;
      blockHeight = (item[START_PRICE_INDEX] - item[END_PRICE_INDEX])*this.perPricePixel;
      blockYPoint = (this.maxPrice - item[START_PRICE_INDEX])*this.perPricePixel;
    } else {
      //涨了 buy
      col = this.buyColor;
      blockHeight = (item[END_PRICE_INDEX] - item[START_PRICE_INDEX])*this.perPricePixel;
      blockYPoint = (this.maxPrice - item[END_PRICE_INDEX])*this.perPricePixel;
    }
    lineHeight = (item[MAX_PRICE_INDEX] - item[MIN_PRICE_INDEX])*this.perPricePixel;
    lineYPoint = (this.maxPrice - item[MAX_PRICE_INDEX])*this.perPricePixel;
    // if (i === 0) console.log(lineHeight, blockHeight, lineYPoint, blockYPoint);
    lineHeight = lineHeight > 2*this.sharpness ? lineHeight : 2*this.sharpness;
    blockHeight = blockHeight > 2*this.sharpness ? blockHeight : 2*this.sharpness;
    if (i === 0) {
      realTime = item[END_PRICE_INDEX];
      realTimeYPoint = blockYPoint + (item[START_PRICE_INDEX] > item[END_PRICE_INDEX] ? blockHeight : 0)
    };
    // 绘制垂直方向的参考线、以及x轴的日期时间
    if (i%this.xDateSpace === (this.fromSpaceNum%this.xDateSpace)) {
      this.drawDash(margin+(blockWidth-1*this.sharpness)/2, 0, margin+(blockWidth-1*this.sharpness)/2, this.centerSpace);
      this.ctx.save();
      // 填充文字时需要把canvas的转换还原回来,防止文字翻转变形
      this.transformOrigin();
      // 翻转后将原点移回翻转前的位置
      this.ctx.translate(this.canvas.width, 0);
      this.drawText(processXDate(item[TIME_INDEX], this.dataType), -(margin+(blockWidth-1*this.sharpness)/2), this.centerSpace + 12*this.sharpness, undefined, 'center', 'top');
      
      this.ctx.restore();
    }
    this.drawRect(margin+(blockWidth-1*this.sharpness)/2, lineYPoint, lineWidth, lineHeight, col);
    this.drawRect(margin, blockYPoint, blockWidth, blockHeight, col);
    margin = margin+blockWidth+blockMargin;
  }
  //绘制实时报价线、价格
  this.drawLine((this.movingRange-RIGHT_SPACE) * this.sharpness, realTimeYPoint, (this.movingRange-RIGHT_SPACE) * this.sharpness + this.canvas.width, realTimeYPoint, '#cccccc');
  this.ctx.save();
  this.ctx.translate(-RIGHT_SPACE * this.sharpness, 0);
  this.transformOrigin();
  this.drawRect((17-this.movingRange) * this.sharpness, realTimeYPoint - 10 * this.sharpness, this.ctx.measureText(realTime).width+6*this.sharpness, 20*this.sharpness, "#ccc");
  this.drawText(realTime, (20-this.movingRange) * this.sharpness, realTimeYPoint);
  this.ctx.restore();
  //最后绘制y轴上报价,放在最上层
  this.ctx.translate(-RIGHT_SPACE * this.sharpness, 0);
  this.drawYPrice();
  this.ctx.restore();
  drawCrossLine.call(this);
}

这个方法不难,只是绘制时为了方便计算位置,需要经常变换原点坐标,不要搞错了就好。

还需要注意的是 sharpness 这个变量,代表清晰度,整个canvas的宽高是在原有的基础上乘上了这个系数得到的,所以,计算时需要特别注意带上这个系数。

5. 更新历史&实时报价方法

// 实时报价
RenderKLine.prototype.updateRealTimeQuote = function (quote) {
  if (!quote) return;
  pushQuoteInData.call(this, quote);
}
/**
 * 历史报价
 * @param {Array} data 数据
 * @param {int}   type 报价类型  默认 60(1小时)
 *    (1, 5, 15, 30, 60, 240, 1440, 10080, 43200)
      (1分钟 5分钟 15分钟 30分钟 1小时 4小时 日 周 月)
 */
RenderKLine.prototype.updateHistoryQuote = function (data, type = 60) {
  if (!data instanceof Array || !data.length) return;
  this.dataArr = data;
  this.dataType = type;
  this.updateData(true);
}

6. 调用demo

<div id="myCanvasBox" ></div>

<script>
    let data = [
      {
        "time": 1576648800, 
        "open_price": "1476.94", 
        "high": "1477.44", 
        "low": "1476.76", 
        "close": "1476.96"
      }, 
      //...
    ];
    let options = {
      sharpness: 3,
      blockWidth: 11,
      horizontalCells: 10
    };
    let kLine = new RenderKLine("myCanvasBox", options);
    //更新历史报价
    kLine.updateHistoryQuote(data);
    //模拟实时报价
    let realTime = `{
      "time": 1575858840, 
      "open_price": "1476.96", 
      "high": "1482.12", 
      "low": "1470.96", 
      "close": "1476.96"
    }`;
    setInterval(() => {
      let realTimeCopy = JSON.parse(realTime);
      realTimeCopy.time = parseInt(new Date().getTime()/1000);
      realTimeCopy.close = (1476.96 - (Math.random() * 4 - 2)).toFixed(2);
      kLine.updateRealTimeQuote(realTimeCopy);
     }, parseInt(Math.random() * 1000 + 500))
</script>

以上就是“在前端开发中如何通过Canvas实现K线图”这篇文章的所有内容,感谢各位的阅读!相信大家阅读完这篇文章都有很大的收获,小编每天都会为大家更新不同的知识,如果还想学习更多的知识,请关注亿速云行业资讯频道。

向AI问一下细节

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

AI