1. The SVG and Canvas

Due to the project requirements of the company, a K-chart is needed to allow traders to clearly see the quotation of a certain trading variety in each period of time, as well as the current real-time quotation.

I have two directions in mind, one is SVG, which is similar to Highcharts and other plug-ins, and the other is CANVAS of HTML5.

SVG is a language for describing 2D graphics using XML. Canvas uses JavaScript to draw 2D graphics. Canvas is rendered pixel by pixel.

SVG
canvas

2. What needs to be met

  • Historical quotes and real-time quotes chart
  • Support drag and drop to view the quotation chart of a historical period
  • Zoom in and out of charts by using the mouse wheel and touch pad
  • Supports mouse pointer movement to view mouse position quotations

3. Code implementation process

1. Preparation

/** ** k-line -k line rendering function * Date: 2019.12.18 Author: isnan */
const BLOCK_MARGIN = 2; // Horizontal spacing of squares
const START_PRICE_INDEX = 'open_price'; // Start price position in the data group
const END_PRICE_INDEX = 'close'; // End the price position in the data group
const MIN_PRICE_INDEX = 'low'; // The position of the minimum price in the data group
const MAX_PRICE_INDEX = 'high'; // The position of the maximum price in the data group
const TIME_INDEX = 'time'; // The position of the time in the data group
const LINE_WIDTH = 1; //1px width (middle line, x axis, etc.)
const BOTTOM_SPACE = 40; // Bottom space
const TOP_SPACE = 20; // Headspace
const RIGHT_SPACE = 60; // Space on the right
let _addEventListener, _removeEventListener, prefix = ' '; //addEventListener is browser compatible
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;  // Clarity (a positive integer that is too large may lag, depending on the configuration of the computer recommended range between 2 and 5)
  this.blockWidth = options.blockWidth; // Width of square (minimum 3, maximum 49)
  this.buyColor = options.buyColor || '#F05452';  / / color
  this.sellColor = options.sellColor || '#25C875';  / / color
  this.fontColor = options.fontColor || '# 666666';  // Text color
  this.lineColor = options.lineColor || '#DDDDDD';  // The color of the guide
  this.digitsPoint = options.digitsPoint || 2; // The digits of the offer
  this.horizontalCells = options.horizontalCells || 5; // How many squares to cut horizontally (middle dotted line = 5-1)
  this.crossLineStatus = options.crossLineStatus || true; // The cursor moves the crosshair to display the status

  //basic params
  this.totalWidth = 0;  / / total width
  this.movingRange = 0; // The horizontal movement of the distance is a positive value, use a negative sign
  this.minPrice = 9999999;
  this.maxPrice = 0; // The smallest/largest data is used to plot the Y-axis
  this.diffPrice = 0;  // The difference between the maximum and minimum quotation
  this.perPricePixel = 0; // How many pixels per unit quote takes up
  this.centerSpace = 0; // Distance from the X-axis to the top of the plot area
  this.xDateSpace = 6;  // How many groups of time intervals are drawn on the X-axis
  this.fromSpaceNum = 0;  // Time on the x axis is drawn from the (fromSpaceNum%xDateSpace) group
  this.dataArr = [];  / / data
  this.lastDataTimestamp = undefined; // The first timestamp in the historical quotation is used to compare with the real-time quotation
  this.buyColorRGB = {r: 0.g: 0.b: 0};
  this.sellColorRGB = {r: 0.g: 0.b: 0};
  
  this.processParams();
  this.init();
}
Copy the code

Defines some constants and variables, generates a constructor, takes two arguments, one is id, the canvas will be inserted into the id box, the second argument is some configuration items, optional.

/** * sharpness {number} sharpness * buyColor {string} color - Up * sellColor {string} color - down * fontColor {string} text color * LineColor {string} Reference color * blockWidth {number} square width * digitsPoint {number} offer number of decimal * horizontalCells {number} Cut several squares horizontally * crossLineStatus {Boolean} Mouse moves cross line to show status */
Copy the code

2. Init method and canvas canvas flip

RenderKLine.prototype.init = function () {
  let cBox = document.getElementById(this.id);
  // Create canvas and get canvas context
  this.canvas = document.createElement("canvas");
  if (this.canvas && this.canvas.getContext) {
    this.ctx = this.canvas.getContext("2d");
  }

  this.canvas.innerHTML = 'Your current browser does not support HTML5 Canvas';
  cBox.appendChild(this.canvas);
  this.actualWidth = cBox.clientWidth;
  this.actualHeight = cBox.clientHeight;
  
  this.enlargeCanvas();
}
Since the drawing area is outside the canvas area, this method is also used in place of clearRect clearing the canvas
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;
  // Convert the Canvas origin coordinates to the upper right corner
  this.transformOrigin();
  // base settings
  this.ctx.lineWidth = LINE_WIDTH*this.sharpness;
  this.ctx.font = `The ${12*this.sharpness}px Arial`;
  // Restore the previous scrolling distance
  this.ctx.translate(-this.movingRange * this.sharpness, 0);
  // console.log(this.movingRange);
}
Copy the code

It is also called the enlargeCanvas method, because the normal canvas origin coordinate is in the upper corner, but the image we need to draw is drawn from the right side. So for the convenience of drawing, I have converted the entire canvas to the upper right corner of the origin.

// Switch coordinate system orientation (origin is in upper left or upper right corner)
RenderKLine.prototype.transformOrigin = function () {
  this.ctx.translate(this.canvas.width, 0);
  this.ctx.scale(- 1.1);
}
Copy the code

One thing to note here is that although it’s ok to flip over and draw some rectangles and lines, it’s not ok to draw text, you need to draw text back, otherwise the text will be flipped over. As shown below:

3. Move, drag, and scroll events

// Monitor mouse movement
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(); }}// Drag events
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 two-finger behavior & mouse wheel
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; // No fixed direction, ignore
      if (e.deltaX < 0) return _this.translateKLine(parseInt(-e.deltaX)); / / to the right
      if (e.deltaX > 0) return _this.translateKLine(parseInt(-e.deltaX)); / / to the left
      if (e.ctrlKey) {
        if (e.deltaY > 0) return _this.scaleKLine(- 1); / / inward
        if (e.deltaY < 0) return _this.scaleKLine(1); / / to the outside
      } else {
        if (e.deltaY > 0) return _this.scaleKLine(1); / / up
        if (e.deltaY < 0) return _this.scaleKLine(- 1); / / down}}}Copy the code
  • As I mentioned in the last article on the roller case, this is how to deal with different situations;
  • The mouse movement event updates the event to this, and then calls updateData to draw the image. The following method is called to draw the crosshair.
function drawCrossLine () {
  if (!this.crossLineStatus || !this.event) return;
  let cRect = this.canvas.getBoundingClientRect();
  //layerX has compatibility issues, use clientX
  let x = this.canvas.width - (this.event.clientX - cRect.left - this.movingRange) * this.sharpness;
  let y = (this.event.clientY - cRect.top) * this.sharpness;
  // Underline the quotation
  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');
  / / quotation
  this.ctx.save();
  this.ctx.translate(this.movingRange * this.sharpness, 0);
  // When filling the text, you need to restore the canvas conversion to prevent text flipping and deformation
  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();
}
Copy the code
  • Drag events pass the distance of the pageX movement to the translateKLine method for horizontal scrolling.
/** * scaleTimes @param {int} scaleTimes scaleTimes @param {int} scaleTimes scaleTimes @param {int} scaleTimes scaleTimes 2 >> this.blockWidth + 2*2 * -3 >> this.blockWidth -3 *2 * * Should be scaled based on the center of the current visible area * so the length of the two sides should have the same proportion of the total length * Formula: (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);
}
// Move the chart
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();
}
Copy the code

4. Core method updateData

All of the drawing is done in this method, so you can use this method to redraw the canvas for whatever you want to do. All you need to do is change some properties on the prototype. For example, if you want to move left and right, you just set this.movingRange and call 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();
  // Start drawing a horizontal line by moving the origin coordinates down TOP_SPACE
  this.ctx.translate(0, TOP_SPACE * this.sharpness);
  this.drawHorizontalLine();
  // Start drawing vertical lines and candles by moving the origin coordinates further to the left of RIGHT_SPACE
  this.ctx.translate(RIGHT_SPACE * this.sharpness, 0);
  // Start drawing candles
  let item, col;
  let lineWidth = LINE_WIDTH * this.sharpness,
      margin = blockMargin = BLOCK_MARGIN*this.sharpness,
      blockWidth = this.blockWidth*this.sharpness;// The spacing and block width multiplied by the clarity factor
  let blockHeight, lineHeight, blockYPoint, lineYPoint; // Single square, single middle line height, y coordinate point
  let realTime, realTimeYPoint; // Real-time (final) quotes and y coordinate points
  for (let i=0; i<this.dataArr.length; i++) {
    item = this.dataArr[i];
    if (item[START_PRICE_INDEX] > item[END_PRICE_INDEX]) {
      / / fell 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 {
      / / get the 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)};// Draw a vertical guide and the date and time of the x axis
    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();
      // When filling the text, you need to restore the canvas conversion to prevent text flipping and deformation
      this.transformOrigin();
      // After the flip, move the origin back to the previous position
      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;
  }
  // Draw the real-time quotation line and price
  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();
  // Finally draw the Y-axis quote, put it on the top layer
  this.ctx.translate(-RIGHT_SPACE * this.sharpness, 0);
  this.drawYPrice();
  this.ctx.restore();
  drawCrossLine.call(this);
}
Copy the code

This method is not difficult, but when you draw it, you need to change the origin coordinates a lot for the convenience of calculating the position, so don’t make a mistake.

It should also be noted that the variable sharpness stands for sharpness. The width and height of the canvas is multiplied by this coefficient, so special attention should be paid to this coefficient when calculating.

5. Update historical & real-time quotation methods

// Real-time quotes
RenderKLine.prototype.updateRealTimeQuote = function (quote) {
  if(! quote)return;
  pushQuoteInData.call(this, quote);
}
/** * @param {Array} data * @param {int} type Quote type default 60(1 hour) * (1, 5, 15, 30, 60, 240, 1440, 10080,) 43200) (1 min 5 min 15 min 30 min 1 hr 4 hr day week month) */
RenderKLine.prototype.updateHistoryQuote = function (data, type = 60) {
  if(! datainstanceof Array| |! data.length)return;
  this.dataArr = data;
  this.dataType = type;
  this.updateData(true);
}
Copy the code

6. Call the 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);
    // Update historical quotes
    kLine.updateHistoryQuote(data);
    // Simulate real-time quotes
    let realTime = ` {" time: "1575858840," open_price ":" 1476.96 ", "high" : "1482.12", "low" : "1470.96", "close" : "1476.96"} `;
    setInterval((a)= > {
      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>
Copy the code

7. Rendering

4. To summarize

This function is not finished yet, there are many other functions and some details need to be developed, such as bezier curve drawing, Loading for the first time, Loading more historical quotes and so on. Now I just briefly summarize the problems encountered this time, as well as some gains, and make detailed records after improvement in the next stage.

This is the first time I have used canvas to draw a complete project, the whole process is very fruitful, I want to try other different things in the future, such as games.

  1. Canvas performance is very high, its animation process, is constantly redraw.
  2. Learn to transform coordinate systems, which is very helpful in drawing images.
  3. Use ctx.save and ctx.restore well
  4. Data beyond the canvas viewable range should be discarded to improve performance.

2020.04.21 optimization

We will continue this project this week. We will complete the development and put it into use in the near future. So some tweaks and optimizations were made, and the redraw performance issues mentioned earlier added a judgment to draw k-plot method (updateData) when iterating dataArr data

/ /...
for (let i=0; i<this.dataArr.length; i++) {
  // If it is not the first data (the first data must be drawn because it is a real-time quote line), and it is beyond the visual range, it will break out of the loop and not draw again to save performance
  if(i ! = =0 && (margin < (this.movingRange - RIGHT_SPACE*2) * this.sharpness || margin > this.movingRange * this.sharpness + this.canvas.width)) {
    margin = margin+blockWidth+blockMargin;
    continue;
  }
  / /...
}
Copy the code
  • Only draw in the visible area, otherwise jump out.