HTML5Canvas实现K线图的示例代码

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

HTML5Canvas实现K线图的⽰例代码
因为公司的项⽬需求,需要做⼀个K线图,可以让交易者清楚的看到某⼀交易品种在各个时间段内的报价,以及当前的实时报价。

我所考虑的有两个⽅向,⼀是类似于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; //⿏标移动⼗字线显⽰状态
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 = []; //数据
stDataTimestamp = 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);
}
这⾥有⼀点需要注意的是,虽然翻转过来绘制⼀些矩形,直线没什么问题,但是绘制⽂本是不⾏的,绘制⽂本需要还原回去,不然⽂字就是翻转过来的状态。

如下图所⽰:
3. 移动、拖拽、滚轮事件
//监听⿏标移动
RenderKLine.prototype.addMouseMove = function () {
this.canvas[_addEventListener](prefix+"mousemove", mosueMoveEvent);
this.canvas[_addEventListener](prefix+"mouseleave", e => {
this.event = undefined;
this.enlargeCanvas();
this.updateData();
});
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();
puteTotalWidth();
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);
puteTotalWidth();
puteSpaceY();
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" style="width: 1000px; height: 500px;"></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>
7. 效果图
4. 总结
这个功能还没有做完,还有很多其他功能以及⼀些细节上需要开发,⽐如贝塞尔曲线的绘制,⾸次加载的Loading,更多历史报价加载等等。

现在只是简单总结⼀下这次遇到的问题,以及⼀些收获,等下⼀阶段完善后再做详细记录。

这是我第⼀次使⽤canvas绘制⼀个完整的项⽬,整个过程还是很有收获的,我想以后还要尝试其他不同的东西,⽐如游戏。

canvas性能⾮常⾼,其实现动画的过程,就是不停的重绘。

要学会转换坐标系,这对绘制图像很有帮助。

要⽤好ctx.save 和 ctx.restore
数学很重要...
以上就是本⽂的全部内容,希望对⼤家的学习有所帮助,也希望⼤家多多⽀持。

相关文档
最新文档