由於公司的項目需求,須要作一個K線圖,可讓交易者清楚的看到某一交易品種在各個時間段內的報價,以及當前的實時報價。javascript
我所考慮的有兩個方向,一是相似於Highcharts等插件的實現方式 -- svg,一是HTML5的canvas。java
SVG 是一種使用 XML 描述 2D 圖形的語言。 Canvas 經過 JavaScript 來繪製 2D 圖形。 Canvas 是逐像素進行渲染的。git
/** * 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的盒子內,第二個參數是一些配置項,可選。canvas
/** * sharpness {number} 清晰度 * buyColor {string} color - 漲 * sellColor {string} color - 跌 * fontColor {string} 文字顏色 * lineColor {string} 參考線顏色 * blockWidth {number} 方塊的寬度 * digitsPoint {number} 報價有幾位小數 * horizontalCells {number} 水平方向切割幾個格子 * crossLineStatus {boolean} 鼠標移動十字線顯示狀態 */
複製代碼
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);
}
複製代碼
這裏有一點須要注意的是,雖然翻轉過來繪製一些矩形,直線沒什麼問題,可是繪製文本是不行的,繪製文本須要還原回去,否則文字就是翻轉過來的狀態。以下圖所示:app
//監聽鼠標移動
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); //向下
}
}
}
複製代碼
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();
}
複製代碼
/** * 縮放圖表 * @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();
}
複製代碼
全部的繪製過程都是在這個方法中完成的,這樣不管想要什麼操做,均可以經過此方法重繪canvas來實現,須要作的只是改變原型上的一些屬性而已,好比想要左右移動,只須要把this.movingRange設置好,再調用updateData就完成了。dom
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);
}
複製代碼
這個方法不難,只是繪製時爲了方便計算位置,須要常常變換原點座標,不要搞錯了就好。svg
還須要注意的是 sharpness 這個變量,表明清晰度,整個canvas的寬高是在原有的基礎上乘上了這個係數獲得的,因此,計算時須要特別注意帶上這個係數。函數
// 實時報價
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);
}
複製代碼
<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>
複製代碼
這個功能尚未作完,還有不少其餘功能以及一些細節上須要開發,好比貝塞爾曲線的繪製,首次加載的Loading,更多歷史報價加載等等。如今只是簡單總結一下此次遇到的問題,以及一些收穫,等下一階段完善後再作詳細記錄。post
這是我第一次使用canvas繪製一個完整的項目,整個過程仍是頗有收穫的,我想之後還要嘗試其餘不一樣的東西,好比遊戲。