The introduction

Before I met an interview machine test, is to use canvas to draw shapes, and support zooming, drag functions. Now I have a moment to share how I did it step by step. It’s a good idea to take a look at the Canvas API, canvas API shuttle, before reading this.

Begin to write

Let’s write out the container Dom and the style HTML

<div id="chart-wrap" class="chart-wrap"></div>
Copy the code

css

html.body {
  margin: 0;
  height: 100%;
  overflow: hidden;
}
.chart-wrap {
  height: calc(100% - 40px);
  margin: 20px;
  box-shadow: 0 0 3px orange;
}
Copy the code


First draw a shape

Create a class called Chart that initializes the canvas in the constructor, writes the function to draw the shape, and renders the canvas. The code is as follows:

class chart {
  // Initial constructor
  constructor(params) {
    var wrapDomStyle = getComputedStyle(params.el);
    this.width = parseInt(wrapDomStyle.width, 10);
    this.height = parseInt(wrapDomStyle.height, 10);

    // Create canvas canvas
    this.El = document.createElement('canvas');
    this.El.height = this.height;
    this.El.width = this.width;
    this.ctx = this.El.getContext('2d');

    params.el.appendChild(this.El);
  }

  // Draw a circle
  drawCircle(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.arc(data.x, data.y, data.r, 0.2 * Math.PI);
    this.ctx.fill();
  }

  // Add shapes
  push(data) {
    this.drawCircle(data); }}// Build the chart object
var chartObj = new chart( { el: document.getElementById('chart-wrap')});// Draw a circle
chartObj.push({
  fillStyle: 'pink'.x: 400.y: 300.r: 50
});
Copy the code

Create an object, pass in the container Dom, initialize a canvas from constructor into the div# chartObj Dom, and assign the created instance to the variable chartObj.

Draw a circle by calling the class’s push method.

Click here to see the code effect

Draw many, many types of shapes

If you want to draw other graphs, you need to add type judgment. The above code is modified as follows:

class chart {
  // Initial constructor
  constructor(params) {
    var wrapDomStyle = getComputedStyle(params.el);
    this.width = parseInt(wrapDomStyle.width, 10);
    this.height = parseInt(wrapDomStyle.height, 10);

    // Create canvas canvas
    this.El = document.createElement('canvas');
    this.El.height = this.height;
    this.El.width = this.width;
    this.ctx = this.El.getContext('2d');

    params.el.appendChild(this.El);
  }

  // Draw a circle
  drawCircle(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.arc(data.x, data.y, data.r, 0.2 * Math.PI);
    this.ctx.fill();
  }
  
  // _____________ add draw line method ____________
  drawLine(data) {
    var arr = data.data.concat()
    var ctx = ctx || this.ctx;  

    ctx.beginPath()
    ctx.moveTo(arr.shift(), arr.shift())
    ctx.lineWidth = data.lineWidth || 1
    do{
      ctx.lineTo(arr.shift(), arr.shift());
    } while (arr.length)

    ctx.stroke();
  }
  
  // ___________ add the draw rectangle method ______________
  drawRect(data) {
    this.ctx.beginPath();
    this.ctx.fillStyle = data.fillStyle;
    this.ctx.fillRect(... data.data); }// ___________ add a method to determine type drawing _____________
  draw(item) {
    switch(item.type){
      case 'line':
        this.drawLine(item)
        break;
      case 'rect':
        this.drawRect(item)
        break;
      case 'circle':
        this.drawCircle(item)
        break; }}// Add shapes
  push(data) {
    this.draw(data); // ____________ modify call draw method ____________}}// Build the chart object
var chartObj = new chart( { el: document.getElementById('chart-wrap')});// Draw a circle
chartObj.push({
  type: 'circle'.// ____________ adds a type __________________
  fillStyle: 'pink'.x: 400.y: 300.r: 50
});

// ___________ add drawing lines __________
chartObj.push({
  type: 'line'.lineWidth: 4.data: [100.90.200.90.250.200.400.200]})// ___________ add draw rectangle __________
chartObj.push({
  type: 'rect'.fillStyle: "#0f00ff".data: [350.400.100.100]})Copy the code

A method and data to draw a rectangle (drawRect), a drawLine (drawLine), and a function to determine the type of render (draw) have been added.

Click here to see the code effect

Add zoom

Adding zooming requires clarifying a few things first.

Scaling Canvas provides two types of methods that can be implemented, one to scale on the current scale and one to scale on the underlying canvas.

Matrix changes are not limited to scaling, but you can change the scaling value without changing other parameters

Scale based on current scaling: scale() scales the current drawing to larger or smaller, transform() replaces the current transformation matrix of the drawing;

This means that the canvas was originally 1, and the first time you magnify it 2 times, it becomes 2, and the second time you magnify it 2 times, it becomes 4

Scale on the underlying canvas: setTransform() resets the current transformation to the identity matrix. Then run transform().

This means that the canvas was originally 1, and the first time it was enlarged by 2 times, it became 2, and the second time it was enlarged by 2 times, it was still 2, because it was reset to 1 and then enlarged again

Here I use setTransform() to scale the canvas

The first step:

Since scaling is required, the current scaling value must be saved by constructor as an argument, as well as saving data under push() and render() to redraw all data

constructor() {
  // Since canvas is drawn based on state, that is, after setting the scale value, the elements drawn will be drawn according to the scale multiple, so we need to save each drawn object.
  this.data = []; 
  this.scale = 1; // The default scaling value is 1
}

// Add shapes
push(data) {
  // Add the save data operation to the push method
  this.data.push(data);
}

// Render the entire graphics canvas
render() {
  this.El.width = this.width

  this.data.forEach(item= > {
    this.draw(item)
  })
}
Copy the code

The second step:

Because the mouse wheel controls when zooming, it is added to listen for the wheel event, and it is added when the mouse is in the canvas, so it does not need to listen for the wheel event when it is not in the canvas.

constructor() {
  // Add a scroll wheel to determine the event
  this.addScaleFunc();
}
 
// Add the zoom function to register and remove the MouseWhell event
addScaleFunc() {
  this.El.addEventListener('mouseenter'.this.addMouseWhell);
  this.El.addEventListener('mouseleave'.this.removeMouseWhell);
}

// Add the mousewhell event
addMouseWhell = () = > {
  document.addEventListener('mousewheel'.this.scrollFunc, {passive: false});
}

// Remove the mousewhell event
removeMouseWhell = () = > {
  document.removeEventListener('mousewheel'.this.scrollFunc, {passive: false});
}
Copy the code

Step 3:

After the wheel event listening is complete, it is time to call the concrete zoom implementation code

constructor() {
  // Scale the data to be used by the implementation
  this.maxScale = 3; // Maximum scaling value
  this.minScale = 1; // Minimum zoom value
  this.step = 0.1;   / / zoom ratio
  this.offsetX = 0;  // Canvas X offset value
  this.offsetY = 0;  // The canvas's Y offset
}

// Scale to calculate
scrollFunc = (e) = > {
  // Prevent default events (external container prevents scrolling while scaling)
  e.preventDefault();

  if(e.wheelDelta){
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0) {this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
      this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY

      this.scale += this.step
    } else {
      this.offsetX += this.scale <= this.minScale ? 0 : offsetX
      this.offsetY += this.scale <= this.minScale ? 0 : offsetY

      this.scale -= this.step
    }

    this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
    
    this.render()
  }
}

// Add Settings to zoom in the type judgement render method
draw() {
  this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
}
Copy the code

Preview the above code effect

Explanation:

The first step is easy to understand the second step, more troublesome is the third step, the following is to explain the third part of the specific zoom implementation.

Cut down your code

scrollFunc = (e) = > {
  // Prevent default events (external container prevents scrolling while scaling)
  e.preventDefault();

  if(e.wheelDelta){
  
    e.wheelDelta > 0 ? this.scale += this.step : this.scale -= this.step
    
    this.render()
  }
}
Copy the code

All it takes is a few lines to scale. Determine if E.Wey delta scrolls up or down, increasing or decreasing the size of this.scale, and finally call Render () to redraw the current canvas.

E.preventdefault () doesn’t have to be explained; everyone knows it addresses default behavior. It is important to explain that {passive: false} must be added to the third argument of the event listener when scrollFunc() is called (the default is {passive: true}), otherwise the default rolling event will not be blocked.

You can comment out the rest of the code in scrollFunc in the demo and see that scaling works, but instead of scaling based on mouse position, it always scales at (0,0) the canvas. So the canvas will be offset to the lower right when zoomed in, so the left and up offset corrections are needed to make zooming look like zooming at mouse position.

Modify the code above as follows:

scrollFunc = (e) = > {
  // Prevent default events (external container prevents scrolling while scaling)
  e.preventDefault();

  if(e.wheelDelta){
  
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0) {this.offsetX -= offsetX
      this.offsetY -= offsetY

      this.scale += this.step
    } else {
      this.offsetX += offsetX
      this.offsetY += offsetY

      this.scale -= this.step
    }
    
    this.render()
  }
}
Copy the code

X, y are the distance between the mouse and the original origin of the canvas, offsetX, offsetY are the offset of this scaling, and then judge whether to zoom in or out to increase or decrease the offset of the overall canvas.

The offset is calculated as follows: mouse distance from the original point (x, Y) divided by the scale value this.scale multiplied by the scale this.step.

Explanation: Because it is usedsetTransform(), so each zoom in or zoom out is based on the original canvas size, so you need to divide by the zoom value to find the distance between the mouse and the original point based on the original zoom.

Explanation: If usedscale()Instead of dividing by the scale value, the current scale value multiplied by the scale value equals the actual scale value

Finally, to improve the zoom function, add the maximum zoom value this.maxScale and the minimum zoom value this.minScale limit, complete the code as follows:

// Scale to calculate
scrollFunc = (e) = > {
  // Prevent default events (external container prevents scrolling while scaling)
  e.preventDefault();

  if(e.wheelDelta){
    var x = e.offsetX - this.offsetX
    var y = e.offsetY - this.offsetY

    var offsetX = (x / this.scale) * this.step
    var offsetY = (y / this.scale) * this.step

    if(e.wheelDelta > 0) {this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
      this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY

      this.scale += this.step
    } else {
      this.offsetX += this.scale <= this.minScale ? 0 : offsetX
      this.offsetY += this.scale <= this.minScale ? 0 : offsetY

      this.scale -= this.step
    }

    this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
    
    this.render()
  }
}
Copy the code

This is done, just call this.render(). In this.render we call this.draw, which calls the setTransform method, which sets the changed scale and offset value to the canvas.

this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
Copy the code


Add drag and drop to the canvas effect

First of all, let’s clarify the dragging steps: mouse down => mouse move => mouse release

Mousedown: we use the mousedown event, and then register the mouse movement event in the down event

Mouse movement: We use the Mousemove event to concretely move the canvas in the mouse movement event

Mouse Release: We use the Mouseup event to remove the mouse movement event from the Mouse release event

The specific code is as follows:

constructor(params) {
  this.wrapDom = params.el;
  this.addDragFunc();
}

// Add drag-and-drop function, decide when to register to remove drag-and-drop function
addDragFunc() {
  this.El.addEventListener('mousedown'.this.addMouseMove);
  document.addEventListener('mouseup'.this.removeMouseMove);
}

// Add mouse movement function to get save current click coordinates
addMouseMove = (e) = > {
  this.targetX = e.offsetX
  this.targetY = e.offsetY

  this.mousedownOriginX = this.offsetX;
  this.mousedownOriginY = this.offsetY;
  
  this.wrapDom.style.cursor = 'grabbing'
  this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}// Remove the mouse movement event
removeMouseMove = () = > {
  this.wrapDom.style.cursor = ' '
  this.El.removeEventListener('mousemove'.this.moveCanvasFunc, false)
  this.El.removeEventListener('mousemove'.this.moveShapeFunc, false)}// Move the canvas
moveCanvasFunc = (e) = > {
  // Get the maximum removable width
  var maxMoveX = this.El.width / 2;
  var maxMoveY = this.El.height / 2;

  var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
  var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);

  this.offsetX = Math.abs(offsetX) > maxMoveX ? this.offsetX : offsetX
  this.offsetY = Math.abs(offsetY) > maxMoveY ? this.offsetY : offsetY
  
  this.render()
}

Copy the code

The above code effect demonstration

The rest of the code is pretty simple, so here’s a detailed explanation of what addMouseMove() and moveCanvasFunc() do.

The addMouseMove function uses targetX, targetY saves the coordinates of mouse clicks, mousedownOriginX, mousedownOriginX saves the overall offset of the canvas at mouse clicks.

Then calculate the overall offset after moving in moveCanvasFunc function. The code in moveCanvasFunc function can be simplified as follows:

moveCanvasFunc = (e) = > {
  var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
  var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
  
  this.render()
}
Copy the code

The rest of the code limits the maximum offset and finally calls this.render()

In general, dragging and pulling the canvas is a little bit easier than scaling, and again this will end with a call to this.render(), which will call the this.draw function, which will call the setTransform method, which will change the scaling value, And the offset value is set to the canvas.

this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
Copy the code

Drag and drop shapes on the canvas

If you want to drag a shape on the canvas, you need to determine whether the mouse click position is in the shape, and because of the hierarchy, you can only control the shape of the top layer.

Therefore, we need to write the judgment method of whether the mouse is inside the shape when it is pressed down. Here, we only write the judgment method of rectangle, circle and line segment.

Because the drag function has been implemented in the implementation of the drag function, now only need to change the addMouseMove function and add the shape movement function, as well as three judgment methods.

The overall code is as follows:

// Add mouse movement function to get save current click coordinates
addMouseMove = (e) = > {

  this.targetX = e.offsetX
  this.targetY = e.offsetY

  this.mousedownOriginX = this.offsetX;
  this.mousedownOriginY = this.offsetY;

  var x = (this.targetX - this.offsetX) / this.scale;
  var y = (this.targetY - this.offsetY) / this.scale;

  this.activeShape = null

  this.data.forEach(item= > {
    switch(item.type){
      case 'rect':
        this.isInnerRect(... item.data, x, y) && (this.activeShape = item)
        break;
      case 'circle':
        this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
        break;
      case 'line':
        var lineNumber = item.data.length / 2 - 1
        var flag = false
        for(let i = 0; i < lineNumber; i++){
          let index = i*2;
          flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
          if(flag){
            this.activeShape = item
            break; }}}})if(!this.activeShape){
    this.wrapDom.style.cursor = 'grabbing'
    this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}else {
    this.wrapDom.style.cursor = 'all-scroll'
    this.shapedOldX = null
    this.shapedOldY = null
    this.El.addEventListener('mousemove'.this.moveShapeFunc, false)}}// Move the shape
moveShapeFunc = (e) = > {
  var moveX = e.offsetX - (this.shapedOldX || this.targetX);
  var moveY = e.offsetY - (this.shapedOldY || this.targetY);
  
  moveX /= this.scale
  moveY /= this.scale

  switch(this.activeShape.type){
    case 'rect':
      let x = this.activeShape.data[0]
      let y = this.activeShape.data[1]
      let width = this.activeShape.data[2]
      let height = this.activeShape.data[3]
      this.activeShape.data = [x + moveX, y + moveY, width, height]
      break;
    case 'circle':
      this.activeShape.x += moveX
      this.activeShape.y += moveY
      break;
    case 'line':
      var item = this.activeShape;
      var lineNumber = item.data.length / 2
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        item.data[index] += moveX
        item.data[index + 1] += moveY
      }
  }
  this.shapedOldX = e.offsetX
  this.shapedOldY = e.offsetY

  this.render()
}

// Check whether the box is inside the rectangle
isInnerRect(x0, y0, width, height, x, y) {
  return x0 <= x && y0 <= y && (x0 + width) >= x && (y0 + height) >= y
}

// Check if it is inside the circle
isInnerCircle(x0, y0, r, x, y) {
  return Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2) < =Math.pow(r, 2)}// Check if it is on a path
isInnerPath(x0, y0, x1, y1, x, y, lineWidth) {
  var a1pow = Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2);
  var a1 = Math.sqrt(a1pow, 2)
  var a2pow = Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)
  var a2 = Math.sqrt(a2pow, 2)

  var a3pow = Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2)
  var a3 = Math.sqrt(a3pow, 2)

  var r = lineWidth / 2
  var ab = (a1pow - a2pow + a3pow) / (2 * a3)var ab = (a1pow - a2pow + a3pow) / (2 * a3)
  var h = Math.sqrt(a1pow - Math.pow(ab, 2), 2)

  var ad = Math.sqrt(Math.pow(a3, 2) + Math.pow(r, 2))

  return h <= r && a1 <= ad && a2 <= ad
}
Copy the code

The above code effect demonstration

This code adds an action in addMouseMove to determine if it is inside the shape.

var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;

this.activeShape = null

this.data.forEach(item= > {
  switch(item.type){
    case 'rect':
      this.isInnerRect(... item.data, x, y) && (this.activeShape = item)
      break;
    case 'circle':
      this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
      break;
    case 'line':
      var lineNumber = item.data.length / 2 - 1
      var flag = false
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
        if(flag){
          this.activeShape = item
          break; }}}})Copy the code

Obtain x and Y coordinates from the origin of the canvas based on the original zoom state according to the mouse position, and call different methods according to different types to determine whether the canvas is in the current shape.

It then registers the event of dragging a canvas or a shape based on whether it is inside the shape

if(!this.activeShape){
  this.wrapDom.style.cursor = 'grabbing'
  this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}else {
  this.wrapDom.style.cursor = 'all-scroll'
  this.shapedOldX = null
  this.shapedOldY = null
  this.El.addEventListener('mousemove'.this.moveShapeFunc, false)}Copy the code

If inside the shape, modify the shape position parameter and call this.render() to re-render the canvas

// Move the shape
moveShapeFunc = (e) = > {
  var moveX = e.offsetX - (this.shapedOldX || this.targetX);
  var moveY = e.offsetY - (this.shapedOldY || this.targetY);
  
  moveX /= this.scale
  moveY /= this.scale

  switch(this.activeShape.type){
    case 'rect':
      let x = this.activeShape.data[0]
      let y = this.activeShape.data[1]
      let width = this.activeShape.data[2]
      let height = this.activeShape.data[3]
      this.activeShape.data = [x + moveX, y + moveY, width, height]
      break;
    case 'circle':
      this.activeShape.x += moveX
      this.activeShape.y += moveY
      break;
    case 'line':
      var item = this.activeShape;
      var lineNumber = item.data.length / 2
      for(let i = 0; i < lineNumber; i++){
        let index = i*2;
        item.data[index] += moveX
        item.data[index + 1] += moveY
      }
  }
  this.shapedOldX = e.offsetX
  this.shapedOldY = e.offsetY

  this.render()
}
Copy the code

Moving the shape also gets the amount of movement of the canvas based on the original scale (you can see above except this.scale), moveX, moveY, and adds the amount of movement to the position coordinates of the selected shape.

Save the current offset this.shapedOldX, this.shapedOldY for the next event.

Determine if it is inside the shape

1. Determine whether it is in a rectangular box according to the x and y coordinates currently calculated, determine whether it is smaller than the x and y coordinates of the rectangle, and determine whether it is larger than the lower-right coordinates of the rectangle (x + width) and (y + height).

According to the x and y coordinates, calculate the distance from the center of the circle. If the distance is less than or equal to the radius of the circle, it indicates that the circle is inside the circle.

Assume that line AB is 90 and click C to determine whether AC or BC is greater than AD. If so,C must not be in the line segment, and the vertical distance CH between C and AB must be less than or equal to half of the line segment width.

Only a single line segment can be judged. If multiple connection lines are not judged accurately, there will be extra parts at the connection that cannot be judged. The diagram below:

This is a line segment of width 90, and the red area can be identified by the above method, but the part that the arrow points to cannot be identified.

It is also left out here because if the Angle between line segments is less than 90deg, the default shape will be:

You can see the miterLimit attribute, lineJoin attribute and lineCap attribute. These attributes have a great influence on line segments. Here we only demonstrate the judgment of single line segment in default state.

conclusion

OK, the above has finished the requirements described at the beginning, interested friends can change the example in the Demo to modify the parameters to see the effect.

If you have any questions or omissions, please correct them. Thank you.